Compare commits

..

8 Commits

Author SHA1 Message Date
Karchnu 4c1b4c43c2 authc 2020-10-13 18:00:48 +02:00
Karchnu 194abdd1f9 recovery get search validate 2020-10-13 17:42:28 +02:00
Karchnu baa86bf667 User deletion. 2020-10-13 06:21:58 +02:00
Karchnu 90ccd50c80 Deletion: WIP 2020-10-13 05:33:49 +02:00
Karchnu c3d5aef951 Better CLI parameters parsing. 2020-10-13 02:35:33 +02:00
Karchnu e88c19c892 removing all kind of stuff 2020-10-11 20:52:04 +02:00
Karchnu 62cef72fb2 new parsing incoming 2020-10-11 15:51:26 +02:00
Karchnu 88f87ef6f2 authc: a start 2020-10-09 18:13:58 +02:00
35 changed files with 1527 additions and 1845 deletions

View File

@ -1,32 +1,43 @@
# authd # authd
authd is a token-based authentication micro-service. authd is a token-based authentication micro-service.
> TODO: explain basic concepts behind `authd`.
## Build ## Build
`authd` is written in Crystal. `authd` is written in Crystal and uses `build.zsh` as Makefile generator, as
Youll need the following tools: well as shards to fetch dependencies.
Youll need the following tools to build authd:
- crystal - crystal
- shards - shards
- build.zsh
- make - make
To build authd, run the following commands: To build authd, run the following commands:
``` ```
shards install
make make
``` ```
Note that if you clone authd from its repository, its `Makefile` may be missing.
In such situations, run `build.zsh -c` to generate it, after which `make` should run fine.
## Deployment ## Deployment
``` ```
$ authd --help $ authd --help
usage: authd [options]
-s directory, --storage directory
Directory in which to store users.
-K file, --key-file file JWT key file
-R --allow-registrations
-h, --help Show this help
$
``` ```
> TODO: documentation on how to deploy, including with the mail server and tools.
### Users storage ### Users storage
The storage directory will default to `./storage`. The storage directory will default to `./storage`.
@ -34,11 +45,29 @@ The storage directory will default to `./storage`.
No SQL database, database management system or other kind of setup is required to run authd and store users. No SQL database, database management system or other kind of setup is required to run authd and store users.
To migrate an instance of authd, a simple copy of the storage directory will be enough. To migrate an instance of authd, a simple copy of the storage directory will be enough.
Make sure your copy preserves symlinks, as those are extensively used.
### Administrating users ### Administrating users
> TODO: document how to manage users through `authc`. The `authd-user-add` and `authd-user-allow` are tools to add users to authds database and to edit their permissions.
The permission level `none` can be used in `authd-user-allow` to remove a permission.
### Key file
authd will provide users with cryptographically signed tokens.
To sign and check those tokens, a shared key is required between authd and services using authd.
authd reads that key from a file to prevent it being visible on the command line when running authd.
Any content is acceptable as a key file.
Example:
```
$ echo "I am a key." > key-file
$ authd -K ./key-file
```
## APIs ## APIs
@ -46,34 +75,22 @@ To migrate an instance of authd, a simple copy of the storage directory will be
authds protocol is still subject to change. authds protocol is still subject to change.
> TODO: document messages.
### Libraries ### Libraries
> TODO: document basic functions in the `AuthD::Client` class to exchange messages with `authd`.
A `AuthD::Client` Crystal class is available to build synchronous clients in Crystal. A `AuthD::Client` Crystal class is available to build synchronous clients in Crystal.
# Authorization rules ```crystal
require "authd"
Logged users can: authd = AuthD::Client.new
- retrieve public data of any user **individually** authd.key = File.read("./some-file").chomp
- change their own data: password, email address, profile entries (except the read-only ones)
- delete their account
- check their own permissions
Admins with 'Read' permission on the '*' resource can: pp! r = authd.get_token?("login", "password")
- list users
- check permissions of other users
Admins with 'Edit' permission on the '*' resource can: pp! r = authd.add_user("login", "password")
- change data of another user
Admins with 'Admin' permission on the '*' resource (or the 'admin' boolean) can: pp! u = authd.get_user?("login", "password").not_nil!
- change read-only profile entries ```
- change permissions
- delete a user
- uprank and downrank admins
## Contributing ## Contributing
@ -81,15 +98,3 @@ Pull requests are welcome. For major changes, please open an issue first to disc
Please make sure to update tests as appropriate. Please make sure to update tests as appropriate.
# WIP: design choices
An user has a number, a login, an email address, a profile (`Hash(String, JSON::Any)`) and permissions.
An `admin` boolean also tells weither or not the user is an administrator.
**Requests work mostly on current user**.
Some take a *UserID* to identify another user (its number or its login, both are valid), which often implies admin permissions.
**Permissions** are: None, Read, Edit, Admin.
Plus, the `admin` boolean value in the `AuthD::User` class.
> TODO: continue explaining design choices.

22
TODO.md
View File

@ -1,22 +0,0 @@
### Consistency in error management
**Both exceptions and error reponses are used**.
A choice should be made between the two options.
A combinaison of both is fine as long as the logic is comprehensively documented.
**Response::Error** class is overused.
A simple error message is given instead of specific messages for each recurring error.
In the same time, some exceptions (such as **AdminAuthenticationException**) are used a few times for the same kind of errors.
### Structures, not classes
Maybe in some cases, it could be great to use structures instead of classes.
They are simpler, use less memory and computation.
### CLI client
Current client **authc** lacks most requests.
### Documentation
Documentation isn't started, yet. TODO!

View File

@ -1,26 +0,0 @@
#!/bin/awk -f
# Provides message parameters and numbers.
# Use: cat src/requests/*.cr | ./bin/get-messages.awk
BEGIN {
OFS="\t"
should_print = 0
}
/def initialize/ {
should_print = 0
print ""
}
# Print line only when we should:
# - when in a message class
# - when the line isn't empty
should_print == 1 && /[0-9a-zA-Z]/ {
print
}
/IPC::JSON.message/ || /IPC::CBOR.message/ {
print $3, $2
should_print = 1
}

View File

@ -1,17 +0,0 @@
#!/bin/sh
# Should we run the build?
if [ $# -lt 1 ]; then
exec >& 2
echo "Usage: $0 <exe>"
exit 1
fi
exe=$1
# If the binary hasn't already be compiled.
[ -f "${exe}" ] || exit 0
v=`find src/ -type f -newer "${exe}" | wc -l`
test "${v}" != "0"

101
makefile
View File

@ -1,101 +0,0 @@
all: build-server
Q ?= @
SHOULD_UPDATE = ./bin/should-update
OPTS ?= --progress
NAME ?= John
EMAIL ?= john@example.com
# For requests where authentication is required.
LOGIN ?=
ifeq ($(LOGIN),)
LOGIN_OPT =
else
LOGIN_OPT = -l $(LOGIN)
endif
##################
### SETUP COMMANDS
##################
PASSWORD_FILE ?= /tmp/PASSWORD
setup:
@[ -f $(PASSWORD_FILE) ] || echo -n "coucou" > $(PASSWORD_FILE)
DATA_DIRECTORY ?= /tmp/DATA-AUTHD
run-authd: setup
./bin/authd -k $(PASSWORD_FILE) -R -E --storage $(DATA_DIRECTORY)
# First user always is the admin.
add-first-user:
./bin/authc bootstrap $(NAME) $(EMAIL)
####################
### REQUEST EXAMPLES
####################
add-user:
./bin/authc user add $(NAME) $(EMAIL) $(LOGIN_OPT)
register:
./bin/authc user register $(NAME) $(EMAIL)
ACTIVATION_KEY ?= put-your-key-here
validate:
./bin/authc user validate $(NAME) $(ACTIVATION_KEY)
get-user:
./bin/authc user get $(NAME) $(LOGIN_OPT)
SERVICE ?= 'auth'
RESOURCE ?= '*'
UID ?= 1000
permission-check:
./bin/authc permission check $(UID) $(SERVICE) $(RESOURCE) $(LOGIN_OPT)
PERMISSION ?= Read
permission-set:
./bin/authc permission set $(UID) $(SERVICE) $(RESOURCE) $(PERMISSION) $(LOGIN_OPT)
###################
### DEVELOPER TOOLS
###################
build-server:
$(Q)-$(SHOULD_UPDATE) bin/authd && shards build authd $(OPTS)
build-client:
$(Q)-$(SHOULD_UPDATE) bin/authc && shards build authc $(OPTS)
build: build-server build-client
doc:
crystal docs
HTTPD_ACCESS_LOGS ?= /tmp/access-authd-docs.log
HTTPD_ADDR ?= 127.0.0.1
HTTPD_PORT ?= 9000
DIR ?= docs
serve-doc:
darkhttpd $(DIR) --addr $(HTTPD_ADDR) --port $(HTTPD_PORT) --log $(HTTPD_ACCESS_LOGS)
print-messages:
cat src/requests/*.cr | ./bin/get-messages.awk
print-message-numbers:
make -s print-messages | grep -E "^[0-9]" | sort -n
print-messages-without-comments:
make -s print-messages | grep -vE '^[[:blank:]]+#'
print-response-messages:
cat src/responses/*.cr | ./bin/get-messages.awk
print-response-message-numbers:
make -s print-response-messages | grep -E "^[0-9]" | sort -n
print-response-messages-without-comments:
make -s print-response-messages | grep -vE '^[[:blank:]]+#'
wipe-db:
rm -r $(DATA_DIRECTORY)
release:
make build-server OPTS="--progress --release"

View File

@ -1,43 +1,38 @@
name: authd name: authd
version: 0.1.0 version: 0.2.0
description: |
JWT-based authentication daemon.
authors: authors:
- Philippe Pittoli <karchnu@karchnu.fr> - Karchnu <karchnu@karchnu.fr>
- Luka Vandervelden <lukc@upyum.com>
description: |
JWT-based authentication daemon.
targets: targets:
authd: authd:
main: src/server.cr main: src/main.cr
authc: authc:
main: src/client.cr main: utils/authc.cr
crystal: 1.7.1 crystal: 0.35.1
dependencies: dependencies:
sodium: grok:
branch: master github: spinscale/grok.cr
github: didactic-drunk/sodium.cr passwd:
grok: git: https://git.baguette.netlib.re/Baguette/passwd.cr
github: spinscale/grok.cr branch: master
passwd: ipc:
git: https://git.baguette.netlib.re/Baguette/passwd.cr git: https://git.baguette.netlib.re/Baguette/ipc.cr
branch: master branch: master
jwt: jwt:
github: crystal-community/jwt github: crystal-community/jwt
branch: master branch: master
baguette-crystal-base: baguette-crystal-base:
git: https://git.baguette.netlib.re/Baguette/baguette-crystal-base git: https://git.baguette.netlib.re/Baguette/baguette-crystal-base
branch: master branch: master
dodb: dodb:
git: https://git.baguette.netlib.re/Baguette/dodb.cr git: https://git.baguette.netlib.re/Baguette/dodb.cr
branch: master branch: master
cbor:
git: https://git.baguette.netlib.re/Baguette/crystal-cbor
branch: master
ipc:
git: https://git.baguette.netlib.re/Baguette/ipc.cr
branch: master
license: ISC license: EUPL

View File

@ -1,43 +1,659 @@
require "uuid" require "json"
require "option_parser"
require "openssl" require "jwt"
require "colorize"
require "jwt"
require "grok"
require "dodb"
require "ipc" require "ipc"
require "baguette-crystal-base" require "./user.cr"
# In any message, a user can be referred by its UInt32 uid or its login. class AuthD::Exception < Exception
alias UserID = UInt32 | String end
# Allows get configuration from a provided file. class AuthD::MalformedRequest < Exception
# See Baguette::Configuration::Base.get getter ipc_type : Int32
class Baguette::Configuration getter payload : String
class Auth < IPC
include YAML::Serializable
property login : String? = nil def initialize(@ipc_type, @payload)
property pass : String? = nil @message = "malformed payload"
property secret_key : String = "nico-nico-nii" # Default authd key, as per the specs. :eyes: end
property secret_key_file : String? = nil end
def initialize class AuthD::Response
include JSON::Serializable
property id : JSON::Any?
annotation MessageType
end
class_getter type = -1
def type
@@type
end
macro inherited
def self.type
::AuthD::Response::Type::{{ @type.name.split("::").last.id }}
end
end
macro initialize(*properties)
def initialize(
{% for value in properties %}
@{{value.id}}{% if value != properties.last %},{% end %}
{% end %}
)
end
def type
Type::{{ @type.name.split("::").last.id }}
end
end
class Error < Response
property reason : String?
initialize :reason
end
class Token < Response
property uid : Int32
property token : String
initialize :token, :uid
end
class User < Response
property user : ::AuthD::User::Public
initialize :user
end
class UserAdded < Response
property user : ::AuthD::User::Public
initialize :user
end
class UserEdited < Response
property uid : Int32
initialize :uid
end
class UserValidated < Response
property user : ::AuthD::User::Public
initialize :user
end
class UsersList < Response
property users : Array(::AuthD::User::Public)
initialize :users
end
class PermissionCheck < Response
property user : Int32
property service : String
property resource : String
property permission : ::AuthD::User::PermissionLevel
initialize :service, :resource, :user, :permission
end
class PermissionSet < Response
property user : Int32
property service : String
property resource : String
property permission : ::AuthD::User::PermissionLevel
initialize :user, :service, :resource, :permission
end
class PasswordRecoverySent < Response
property user : ::AuthD::User::Public
initialize :user
end
class PasswordRecovered < Response
property user : ::AuthD::User::Public
initialize :user
end
class MatchingUsers < Response
property users : Array(::AuthD::User::Public)
initialize :users
end
# This creates a Request::Type enumeration. One entry for each request type.
{% begin %}
enum Type
{% for ivar in @type.subclasses %}
{% klass = ivar.name %}
{% name = ivar.name.split("::").last.id %}
{% a = ivar.annotation(MessageType) %}
{% if a %}
{% value = a[0] %}
{{ name }} = {{ value }}
{% else %}
{{ name }}
{% end %}
{% end %}
end
{% end %}
# This is an array of all requests types.
{% begin %}
class_getter requests = [
{% for ivar in @type.subclasses %}
{% klass = ivar.name %}
{{klass}},
{% end %}
]
{% end %}
def self.from_ipc(message : IPC::Message) : Response?
payload = String.new message.payload
type = Type.new message.utype.to_i
begin
requests.find(&.type.==(type)).try &.from_json(payload)
rescue e : JSON::ParseException
raise MalformedRequest.new message.utype.to_i, payload
end end
end end
end end
# Token and user classes. class AuthD::Request
require "./authd/token.cr" include JSON::Serializable
require "./authd/user.cr"
# Requests and responses. property id : JSON::Any?
require "./authd/exceptions"
# Requests and responses. annotation MessageType
require "./network" end
class_getter type = -1
macro inherited
def self.type
::AuthD::Request::Type::{{ @type.name.split("::").last.id }}
end
end
macro initialize(*properties)
def initialize(
{% for value in properties %}
@{{value.id}}{% if value != properties.last %},{% end %}
{% end %}
)
end
def type
Type::{{ @type.name.split("::").last.id }}
end
end
class GetToken < Request
property login : String
property password : String
initialize :login, :password
end
class AddUser < Request
# Only clients that have the right shared key will be allowed
# to create users.
property shared_key : String
property login : String
property password : String
property email : String?
property phone : String?
property profile : Hash(String, JSON::Any)?
initialize :shared_key, :login, :password, :email, :phone, :profile
end
class ValidateUser < Request
property login : String
property activation_key : String
initialize :login, :activation_key
end
class GetUser < Request
property user : Int32 | String
initialize :user
end
class GetUserByCredentials < Request
property login : String
property password : String
initialize :login, :password
end
class ModUser < Request
property shared_key : String
property user : Int32 | String
property password : String?
property email : String?
property phone : String?
property avatar : String?
initialize :shared_key, :user
end
class Register < Request
property login : String
property password : String
property email : String?
property phone : String?
property profile : Hash(String, JSON::Any)?
initialize :login, :password, :email, :phone, :profile
end
class UpdatePassword < Request
property login : String
property old_password : String
property new_password : String
end
class ListUsers < Request
property token : String?
property key : String?
end
class CheckPermission < Request
property shared_key : String
property user : Int32 | String
property service : String
property resource : String
initialize :shared_key, :user, :service, :resource
end
class SetPermission < Request
property shared_key : String
property user : Int32 | String
property service : String
property resource : String
property permission : ::AuthD::User::PermissionLevel
initialize :shared_key, :user, :service, :resource, :permission
end
class PasswordRecovery < Request
property user : Int32 | String
property password_renew_key : String
property new_password : String
initialize :user, :password_renew_key, :new_password
end
class AskPasswordRecovery < Request
property user : Int32 | String
property email : String
initialize :user, :email
end
class SearchUser < Request
property user : String
initialize :user
end
class EditProfile < Request
property token : String
property new_profile : Hash(String, JSON::Any)
initialize :token, :new_profile
end
# Same as above, but doesnt reset the whole profile, only resets elements
# for which keys are present in `new_profile`.
class EditProfileContent < Request
property token : String?
property shared_key : String?
property user : Int32 | String | Nil
property new_profile : Hash(String, JSON::Any)
initialize :shared_key, :user, :new_profile
initialize :token, :new_profile
end
class EditContacts < Request
property token : String
property email : String?
property phone : String?
end
class Delete < Request
# Deletion can be triggered by either an admin or the user.
property shared_key : String?
property login : String?
property password : String?
property user : String | Int32
initialize :user, :login, :password
initialize :user, :shared_key
end
# This creates a Request::Type enumeration. One entry for each request type.
{% begin %}
enum Type
{% for ivar in @type.subclasses %}
{% klass = ivar.name %}
{% name = ivar.name.split("::").last.id %}
{% a = ivar.annotation(MessageType) %}
{% if a %}
{% value = a[0] %}
{{ name }} = {{ value }}
{% else %}
{{ name }}
{% end %}
{% end %}
end
{% end %}
# This is an array of all requests types.
{% begin %}
class_getter requests = [
{% for ivar in @type.subclasses %}
{% klass = ivar.name %}
{{klass}},
{% end %}
]
{% end %}
def self.from_ipc(message : IPC::Message) : Request?
payload = String.new message.payload
type = Type.new message.utype.to_i
begin
requests.find(&.type.==(type)).try &.from_json(payload)
rescue e : JSON::ParseException
raise MalformedRequest.new message.utype.to_i, payload
end
end
end
module AuthD
class Client < IPC::Client
property key : String
def initialize
@key = ""
initialize "auth"
end
def get_token?(login : String, password : String) : String?
send Request::GetToken.new login, password
response = Response.from_ipc read
if response.is_a?(Response::Token)
response.token
else
nil
end
end
def get_user?(login : String, password : String) : AuthD::User::Public?
send Request::GetUserByCredentials.new login, password
response = Response.from_ipc read
if response.is_a? Response::User
response.user
else
nil
end
end
def get_user?(uid_or_login : Int32 | String) : ::AuthD::User::Public?
send Request::GetUser.new uid_or_login
response = Response.from_ipc read
if response.is_a? Response::User
response.user
else
nil
end
end
def send(type : Request::Type, payload)
send_now @server_fd, type.value.to_u8, payload
end
def decode_token(token)
user, meta = JWT.decode token, @key, JWT::Algorithm::HS256
user = ::AuthD::User::Public.from_json user.to_json
{user, meta}
end
# FIXME: Extra options may be useful to implement here.
def add_user(login : String, password : String,
email : String?,
phone : String?,
profile : Hash(String, JSON::Any)?) : ::AuthD::User::Public | Exception
send Request::AddUser.new @key, login, password, email, phone, profile
response = Response.from_ipc read
case response
when Response::UserAdded
response.user
when Response::Error
raise Exception.new response.reason
else
# Should not happen in serialized connections, but…
# itll happen if you run several requests at once.
Exception.new
end
end
def validate_user(login : String, activation_key : String) : ::AuthD::User::Public | Exception
send Request::ValidateUser.new login, activation_key
response = Response.from_ipc read
case response
when Response::UserValidated
response.user
when Response::Error
raise Exception.new response.reason
else
# Should not happen in serialized connections, but…
# itll happen if you run several requests at once.
Exception.new
end
end
def ask_password_recovery(uid_or_login : String | Int32, email : String)
send Request::AskPasswordRecovery.new uid_or_login, email
response = Response.from_ipc read
case response
when Response::PasswordRecoverySent
when Response::Error
raise Exception.new response.reason
else
Exception.new
end
end
def change_password(uid_or_login : String | Int32, new_pass : String, renew_key : String)
send Request::PasswordRecovery.new uid_or_login, renew_key, new_pass
response = Response.from_ipc read
case response
when Response::PasswordRecovered
when Response::Error
raise Exception.new response.reason
else
Exception.new
end
end
def register(login : String,
password : String,
email : String?,
phone : String?,
profile : Hash(String, JSON::Any)?) : ::AuthD::User::Public?
send Request::Register.new login, password, email, phone, profile
response = Response.from_ipc read
case response
when Response::UserAdded
when Response::Error
raise Exception.new response.reason
end
end
def mod_user(uid_or_login : Int32 | String, password : String? = nil, email : String? = nil, phone : String? = nil, avatar : String? = nil) : Bool | Exception
request = Request::ModUser.new @key, uid_or_login
request.password = password if password
request.email = email if email
request.phone = phone if phone
request.avatar = avatar if avatar
send request
response = Response.from_ipc read
case response
when Response::UserEdited
true
when Response::Error
Exception.new response.reason
else
Exception.new "???"
end
end
def check_permission(user : Int32, service_name : String, resource_name : String) : User::PermissionLevel
request = Request::CheckPermission.new @key, user, service_name, resource_name
send request
response = Response.from_ipc read
case response
when Response::PermissionCheck
response.permission
when Response
raise Exception.new "unexpected response: #{response.type}"
else
raise Exception.new "unexpected response"
end
end
def set_permission(uid : Int32, service : String, resource : String, permission : User::PermissionLevel)
request = Request::SetPermission.new @key, uid, service, resource, permission
send request
response = Response.from_ipc read
case response
when Response::PermissionSet
true
when Response
raise Exception.new "unexpected response: #{response.type}"
else
raise Exception.new "unexpected response"
end
end
def search_user(user_login : String)
send Request::SearchUser.new user_login
response = Response.from_ipc read
case response
when Response::MatchingUsers
response.users
when Response::Error
raise Exception.new response.reason
else
Exception.new
end
end
def edit_profile_content(user : Int32 | String, new_values)
send Request::EditProfileContent.new key, user, new_values
response = Response.from_ipc read
case response
when Response::User
response.user
when Response::Error
raise Exception.new response.reason
else
raise Exception.new "unexpected response"
end
end
def delete(user : Int32 | String, key : String)
send Request::Delete.new user, key
delete_
end
def delete(user : Int32 | String, login : String, pass : String)
send Request::Delete.new user, login, pass
delete_
end
def delete_
response = Response.from_ipc read
case response
when Response::Error
raise Exception.new response.reason
end
response
end
end
end
class IPC::Context
def send(fd, response : AuthD::Response)
send fd, response.type.to_u8, response.to_json
end
end
class IPC::Client
def send(request : AuthD::Request)
unless (fd = @server_fd).nil?
send_now fd, request.type.to_u8, request.to_json
else
raise "Client not connected to the server"
end
end
end
# Functions to request the authd server.
require "./authd/client.cr"

View File

@ -1,192 +0,0 @@
require "ipc/json"
require "json"
module AuthD
class Client < IPC
property server_fd : Int32 = -1
def initialize
super
fd = self.connect "auth"
if fd.nil?
raise "couldn't connect to 'auth' IPC service"
end
@server_fd = fd
end
def read
slice = self.read @server_fd
m = IPCMessage::TypedMessage.deserialize slice
m.not_nil!
end
# `parse_message` only raises exception in case the response could not be anticipated.
# Any possibly expected error for a request should be in `expected_messages`.
def parse_message(expected_messages, message)
em = Array(IPC::JSON.class).new
expected_messages.each do |e|
em << e
end
# response = AuthD.responses.parse_ipc_json read
em.parse_ipc_json message
end
def login?(login : String, password : String)
send_now Request::Login.new login, password
parse_message [Response::Login, Response::ErrorInvalidCredentials], read
end
def get_user?(uid_or_login : UserID)
send_now Request::GetUser.new uid_or_login
parse_message [
Response::User,
Response::ErrorMustBeAuthenticated,
Response::ErrorUserNotFound
], read
end
def send_now(msg : IPC::JSON)
m = IPCMessage::TypedMessage.new msg.type.to_u8, msg.to_json
write @server_fd, m
end
def send_now(type : Request::Type, payload)
m = IPCMessage::TypedMessage.new type.value.to_u8, payload
write @server_fd, m
end
# FIXME: Extra options may be useful to implement here.
def add_user(login : String, password : String,
admin : Bool,
email : String?,
profile : Hash(String, ::JSON::Any)?)
send_now Request::AddUser.new login, password, admin, email, profile
parse_message [
Response::UserAdded,
Response::ErrorMustBeAuthenticated,
Response::ErrorAlreadyUsedLogin,
Response::ErrorMailRequired
], read
end
def bootstrap(login : String,
password : String,
email : String,
profile : Hash(String, ::JSON::Any)? = nil)
send_now Request::BootstrapFirstAdmin.new login, password, email, profile
parse_message [Response::UserAdded,Response::ErrorAlreadyUsersInDB], read
end
def decode_token(token)
send_now Request::DecodeToken.new token
parse_message [
Response::User,
Response::ErrorMustBeAuthenticated,
Response::ErrorUserNotFound
], read
end
def validate_user(login : String, activation_key : String)
send_now Request::ValidateUser.new login, activation_key
parse_message [
Response::UserValidated,
Response::ErrorUserNotFound,
Response::ErrorUserAlreadyValidated,
Response::ErrorInvalidActivationKey
], read
end
def ask_password_recovery(uid_or_login : UserID)
send_now Request::AskPasswordRecovery.new uid_or_login
parse_message [Response::PasswordRecoverySent, Response::ErrorUserNotFound], read
end
def change_password(uid_or_login : UserID, new_pass : String, renew_key : String)
send_now Request::PasswordRecovery.new uid_or_login, renew_key, new_pass
parse_message [
Response::PasswordRecovered,
Response::ErrorUserNotFound,
Response::ErrorInvalidRenewKey
], read
end
def register(login : String,
password : String,
email : String?,
profile : Hash(String, ::JSON::Any)?)
send_now Request::Register.new login, password, email, profile
parse_message [
Response::UserAdded,
Response::ErrorRegistrationsClosed,
Response::ErrorAlreadyUsedLogin,
Response::ErrorInvalidLoginFormat,
Response::ErrorMailRequired,
Response::ErrorInvalidEmailFormat,
Response::ErrorPasswordTooShort,
Response::ErrorPasswordTooLong,
Response::ErrorCannotContactUser
], read
end
def mod_user(uid_or_login : UserID, password : String? = nil, email : String? = nil)
request = Request::ModUser.new uid_or_login
request.password = password if password
request.email = email if email
send_now request
parse_message [
Response::ErrorMustBeAuthenticated,
Response::ErrorUserNotFound,
Response::UserEdited
], read
end
def check_permission(user : UserID, service_name : String, resource_name : String)
request = Request::CheckPermission.new user, service_name, resource_name
send_now request
parse_message [
Response::PermissionCheck,
Response::ErrorMustBeAuthenticated,
Response::ErrorUserNotFound
], read
end
def set_permission(user : UserID, service : String, resource : String, permission : User::PermissionLevel)
request = Request::SetPermission.new user, service, resource, permission
send_now request
parse_message [
Response::PermissionSet,
Response::ErrorMustBeAuthenticated,
Response::ErrorUserNotFound
], read
end
def search_user(user_login : String)
send_now Request::SearchUser.new user_login
parse_message [Response::MatchingUsers, Response::ErrorMustBeAuthenticated], read
end
def edit_profile_content(user : UserID, new_values)
send_now Request::EditProfileEntries.new user, new_values
parse_message [
Response::User,
Response::ErrorMustBeAuthenticated,
Response::ErrorUserNotFound,
Response::ErrorReadOnlyProfileKeys
], read
end
def delete(user : UserID)
send_now Request::Delete.new user
parse_message [
Response::ErrorMustBeAuthenticated,
Response::ErrorUserNotFound,
Response::UserDeleted
], read
end
end
end

View File

@ -1,13 +0,0 @@
module AuthD
class Exception < ::Exception
end
class UserNotFound < ::Exception
end
class AuthenticationInfoLacking < ::Exception
end
class AdminAuthorizationException < ::Exception
end
end

698
src/main.cr Normal file
View File

@ -0,0 +1,698 @@
require "uuid"
require "option_parser"
require "openssl"
require "colorize"
require "jwt"
require "ipc"
require "dodb"
require "baguette-crystal-base"
require "grok"
require "./authd.cr"
extend AuthD
class AuthD::Service
property registrations_allowed = false
property require_email = false
property mailer_activation_url : String? = nil
property mailer_field_from : String? = nil
property mailer_field_subject : String? = nil
property read_only_profile_keys = Array(String).new
@users_per_login : DODB::Index(User)
@users_per_uid : DODB::Index(User)
def initialize(@storage_root : String, @jwt_key : String)
@users = DODB::DataBase(User).new @storage_root
@users_per_uid = @users.new_index "uid", &.uid.to_s
@users_per_login = @users.new_index "login", &.login
@last_uid_file = "#{@storage_root}/last_used_uid"
end
def hash_password(password : String) : String
digest = OpenSSL::Digest.new "sha256"
digest << password
digest.hexdigest
end
def new_uid
begin
uid = File.read(@last_uid_file).to_i
rescue
uid = 999
end
uid += 1
File.write @last_uid_file, uid.to_s
uid
end
def handle_request(request : AuthD::Request?)
case request
when Request::GetToken
begin
user = @users_per_login.get request.login
rescue e : DODB::MissingEntry
return Response::Error.new "invalid credentials"
end
if user.nil?
return Response::Error.new "invalid credentials"
end
if user.password_hash != hash_password request.password
return Response::Error.new "invalid credentials"
end
user.date_last_connection = Time.local
token = user.to_token
# change the date of the last connection
@users_per_uid.update user.uid.to_s, user
Response::Token.new (token.to_s @jwt_key), user.uid
when Request::AddUser
# No verification of the users' informations when an admin adds it.
# No mail address verification.
if request.shared_key != @jwt_key
return Response::Error.new "invalid authentication key"
end
if @users_per_login.get? request.login
return Response::Error.new "login already used"
end
if @require_email && request.email.nil?
return Response::Error.new "email required"
end
password_hash = hash_password request.password
uid = new_uid
user = User.new uid, request.login, password_hash
user.contact.email = request.email unless request.email.nil?
user.contact.phone = request.phone unless request.phone.nil?
request.profile.try do |profile|
user.profile = profile
end
# We consider adding the user as a registration
user.date_registration = Time.local
@users << user
Response::UserAdded.new user.to_public
when Request::ValidateUser
user = @users_per_login.get? request.login
if user.nil?
return Response::Error.new "user not found"
end
if user.contact.activation_key.nil?
return Response::Error.new "user already validated"
end
# remove the user contact activation key: the email is validated
if user.contact.activation_key == request.activation_key
user.contact.activation_key = nil
else
return Response::Error.new "wrong activation key"
end
@users_per_uid.update user.uid.to_s, user
Response::UserValidated.new user.to_public
when Request::GetUserByCredentials
user = @users_per_login.get? request.login
unless user
return Response::Error.new "invalid credentials"
end
if hash_password(request.password) != user.password_hash
return Response::Error.new "invalid credentials"
end
user.date_last_connection = Time.local
# change the date of the last connection
@users_per_uid.update user.uid.to_s, user
Response::User.new user.to_public
when Request::GetUser
uid_or_login = request.user
user = if uid_or_login.is_a? Int32
@users_per_uid.get? uid_or_login.to_s
else
@users_per_login.get? uid_or_login
end
if user.nil?
return Response::Error.new "user not found"
end
Response::User.new user.to_public
when Request::ModUser
if request.shared_key != @jwt_key
return Response::Error.new "invalid authentication key"
end
uid_or_login = request.user
user = if uid_or_login.is_a? Int32
@users_per_uid.get? uid_or_login.to_s
else
@users_per_login.get? uid_or_login
end
unless user
return Response::Error.new "user not found"
end
request.password.try do |s|
user.password_hash = hash_password s
end
request.email.try do |email|
user.contact.email = email
end
request.phone.try do |phone|
user.contact.phone = phone
end
@users_per_uid.update user.uid.to_s, user
Response::UserEdited.new user.uid
when Request::Register
if ! @registrations_allowed
return Response::Error.new "registrations not allowed"
end
if @users_per_login.get? request.login
return Response::Error.new "login already used"
end
if @require_email && request.email.nil?
return Response::Error.new "email required"
end
mailer_activation_url = @mailer_activation_url
if mailer_activation_url.nil?
# In this case we should not accept its registration.
return Response::Error.new "No activation URL were entered. Cannot send activation mails."
end
if ! request.email.nil?
# Test on the email address format.
grok = Grok.new [ "%{EMAILADDRESS:email}" ]
result = grok.parse request.email.not_nil!
email = result["email"]?
if email.nil?
return Response::Error.new "invalid email format"
end
end
uid = new_uid
password = hash_password request.password
user = User.new uid, request.login, password
user.contact.email = request.email unless request.email.nil?
user.contact.phone = request.phone unless request.phone.nil?
request.profile.try do |profile|
user.profile = profile
end
user.date_registration = Time.local
begin
mailer_field_subject = @mailer_field_subject.not_nil!
mailer_field_from = @mailer_field_from.not_nil!
mailer_activation_url = @mailer_activation_url.not_nil!
u_login = user.login
u_email = user.contact.email.not_nil!
u_activation_key = user.contact.activation_key.not_nil!
# Once the user is created and stored, we try to contact him
unless Process.run("activation-mailer", [
"-l", u_login,
"-e", u_email,
"-t", mailer_field_subject,
"-f", mailer_field_from,
"-u", mailer_activation_url,
"-a", u_activation_key
]).success?
raise "cannot contact user #{user.login} address #{user.contact.email}"
end
rescue e
Baguette::Log.error "activation-mailer: #{e}"
return Response::Error.new "cannot contact the user (not registered)"
end
# add the user only if we were able to send the confirmation mail
@users << user
Response::UserAdded.new user.to_public
when Request::UpdatePassword
user = @users_per_login.get? request.login
unless user
return Response::Error.new "invalid credentials"
end
if hash_password(request.old_password) != user.password_hash
return Response::Error.new "invalid credentials"
end
user.password_hash = hash_password request.new_password
@users_per_uid.update user.uid.to_s, user
Response::UserEdited.new user.uid
when Request::ListUsers
# FIXME: Lines too long, repeatedly (>80c with 4c tabs).
request.token.try do |token|
user = get_user_from_token token
return Response::Error.new "unauthorized (user not found from token)"
return Response::Error.new "unauthorized (user not in authd group)" unless user.permissions["authd"]?.try(&.["*"].>=(User::PermissionLevel::Read))
end
request.key.try do |key|
return Response::Error.new "unauthorized (wrong shared key)" unless key == @jwt_key
end
return Response::Error.new "unauthorized (no key nor token)" unless request.key || request.token
Response::UsersList.new @users.to_h.map &.[1].to_public
when Request::CheckPermission
unless request.shared_key == @jwt_key
return Response::Error.new "unauthorized"
end
user = @users_per_uid.get? request.user.to_s
if user.nil?
return Response::Error.new "no such user"
end
service = request.service
service_permissions = user.permissions[service]?
if service_permissions.nil?
return Response::PermissionCheck.new service, request.resource, user.uid, User::PermissionLevel::None
end
resource_permissions = service_permissions[request.resource]?
if resource_permissions.nil?
return Response::PermissionCheck.new service, request.resource, user.uid, User::PermissionLevel::None
end
return Response::PermissionCheck.new service, request.resource, user.uid, resource_permissions
when Request::SetPermission
unless request.shared_key == @jwt_key
return Response::Error.new "unauthorized"
end
user = @users_per_uid.get? request.user.to_s
if user.nil?
return Response::Error.new "no such user"
end
service = request.service
service_permissions = user.permissions[service]?
if service_permissions.nil?
service_permissions = Hash(String, User::PermissionLevel).new
user.permissions[service] = service_permissions
end
if request.permission.none?
service_permissions.delete request.resource
else
service_permissions[request.resource] = request.permission
end
@users_per_uid.update user.uid.to_s, user
Response::PermissionSet.new user.uid, service, request.resource, request.permission
when Request::AskPasswordRecovery
uid_or_login = request.user
user = if uid_or_login.is_a? Int32
@users_per_uid.get? uid_or_login.to_s
else
@users_per_login.get? uid_or_login
end
if user.nil?
return Response::Error.new "no such user"
end
if user.contact.email != request.email
# Same error as when users are not found.
return Response::Error.new "no such user"
end
user.password_renew_key = UUID.random.to_s
@users_per_uid.update user.uid.to_s, user
unless (mailer_activation_url = @mailer_activation_url).nil?
mailer_field_from = @mailer_field_from.not_nil!
mailer_activation_url = @mailer_activation_url.not_nil!
# Once the user is created and stored, we try to contact him
unless Process.run("password-recovery-mailer", [
"-l", user.login,
"-e", user.contact.email.not_nil!,
"-t", "Password recovery email",
"-f", mailer_field_from,
"-u", mailer_activation_url,
"-a", user.password_renew_key.not_nil!
]).success?
return Response::Error.new "cannot contact the user for password recovery"
end
end
Response::PasswordRecoverySent.new user.to_public
when Request::PasswordRecovery
uid_or_login = request.user
user = if uid_or_login.is_a? Int32
@users_per_uid.get? uid_or_login.to_s
else
@users_per_login.get? uid_or_login
end
if user.nil?
return Response::Error.new "user not found"
end
if user.password_renew_key == request.password_renew_key
user.password_hash = hash_password request.new_password
else
return Response::Error.new "renew key not valid"
end
user.password_renew_key = nil
@users_per_uid.update user.uid.to_s, user
Response::PasswordRecovered.new user.to_public
when Request::SearchUser
pattern = Regex.new request.user, Regex::Options::IGNORE_CASE
matching_users = Array(AuthD::User::Public).new
users = @users.to_a
users.each do |u|
if pattern =~ u.login || u.profile.try do |profile|
full_name = profile["full_name"]?
if full_name.nil?
false
else
pattern =~ full_name.as_s
end
end
Baguette::Log.debug "#{u.login} matches #{pattern}"
matching_users << u.to_public
else
Baguette::Log.debug "#{u.login} doesn't match #{pattern}"
end
end
Response::MatchingUsers.new matching_users
when Request::EditProfile
user = get_user_from_token request.token
return Response::Error.new "invalid user" unless user
new_profile = request.new_profile
profile = user.profile || Hash(String, JSON::Any).new
@read_only_profile_keys.each do |key|
if new_profile[key]? != profile[key]?
return Response::Error.new "tried to edit read only key"
end
end
user.profile = new_profile
@users_per_uid.update user.uid.to_s, user
Response::User.new user.to_public
when Request::EditProfileContent
user = if token = request.token
user = get_user_from_token token
return Response::Error.new "invalid user" unless user
user
elsif shared_key = request.shared_key
return Response::Error.new "invalid shared key" if shared_key != @jwt_key
user = request.user
return Response::Error.new "invalid user" unless user
user = if user.is_a? Int32
@users_per_uid.get? user.to_s
else
@users_per_login.get? user
end
return Response::Error.new "invalid user" unless user
user
else
return Response::Error.new "no token or shared_key/user pair"
end
new_profile = user.profile || Hash(String, JSON::Any).new
unless request.shared_key
@read_only_profile_keys.each do |key|
if request.new_profile.has_key? key
return Response::Error.new "tried to edit read only key"
end
end
end
request.new_profile.each do |key, value|
new_profile[key] = value
end
user.profile = new_profile
@users_per_uid.update user.uid.to_s, user
Response::User.new user.to_public
when Request::EditContacts
user = get_user_from_token request.token
return Response::Error.new "invalid user" unless user
if email = request.email
# FIXME: This *should* require checking the new mail, with
# a new activation key and everything else.
user.contact.email = email
end
@users_per_uid.update user
Response::UserEdited.new user.uid
when Request::Delete
uid_or_login = request.user
user_to_delete = if uid_or_login.is_a? Int32
@users_per_uid.get? uid_or_login.to_s
else
@users_per_login.get? uid_or_login
end
if user_to_delete.nil?
return Response::Error.new "invalid user"
end
# Either the request comes from an admin or the user.
# Shared key == admin, check the key.
if key = request.shared_key
return Response::Error.new "unauthorized (wrong shared key)" unless key == @jwt_key
else
login = request.login
pass = request.password
if login.nil? || pass.nil?
return Response::Error.new "authentication failed (no shared key, no login)"
end
# authenticate the user
begin
user = @users_per_login.get login
rescue e : DODB::MissingEntry
return Response::Error.new "invalid credentials"
end
if user.nil?
return Response::Error.new "invalid credentials"
end
if user.password_hash != hash_password pass
return Response::Error.new "invalid credentials"
end
# Is the user to delete the requesting user?
if user.uid != user_to_delete.uid
return Response::Error.new "invalid credentials"
end
end
# User or admin is now verified: let's proceed with the user deletion.
@users_per_login.delete user_to_delete.login
# TODO: better response
Response::User.new user_to_delete.to_public
else
Response::Error.new "unhandled request type"
end
end
def get_user_from_token(token : String)
token_payload = Token.from_s(@jwt_key, token)
@users_per_uid.get? token_payload.uid.to_s
end
def run
##
# Provides a JWT-based authentication scheme for service-specific users.
server = IPC::Server.new "auth"
server.base_timer = 30000 # 30 seconds
server.timer = 30000 # 30 seconds
server.loop do |event|
if event.is_a? IPC::Exception
Baguette::Log.error "IPC::Exception"
pp! event
next
end
case event
when IPC::Event::Timer
Baguette::Log.debug "Timer"
when IPC::Event::MessageReceived
begin
request = Request.from_ipc(event.message).not_nil!
Baguette::Log.info "<< #{request.class.name.sub /^Request::/, ""}"
response = handle_request request
response.id = request.id
server.send event.fd, response
rescue e : MalformedRequest
Baguette::Log.error "#{e.message}"
Baguette::Log.error " .. type was: #{e.ipc_type}"
Baguette::Log.error " .. payload was: #{e.payload}"
response = Response::Error.new e.message
rescue e
Baguette::Log.error "#{e.message}"
response = Response::Error.new e.message
end
Baguette::Log.info ">> #{response.class.name.sub /^Response::/, ""}"
end
end
end
end
authd_storage = "storage"
authd_jwt_key = "nico-nico-nii"
authd_registrations = false
authd_require_email = false
activation_url : String? = nil
field_subject : String? = nil
field_from : String? = nil
read_only_profile_keys = Array(String).new
begin
OptionParser.parse do |parser|
parser.banner = "usage: authd [options]"
parser.on "-s directory", "--storage directory", "Directory in which to store users." do |directory|
authd_storage = directory
end
parser.on "-K file", "--key-file file", "JWT key file" do |file_name|
authd_jwt_key = File.read(file_name).chomp
end
parser.on "-R", "--allow-registrations" do
authd_registrations = true
end
parser.on "-E", "--require-email" do
authd_require_email = true
end
parser.on "-t subject", "--subject title", "Subject of the email." do |s|
field_subject = s
end
parser.on "-f from-email", "--from email", "'From:' field to use in activation email." do |f|
field_from = f
end
parser.on "-u", "--activation-url url", "Activation URL." do |opt|
activation_url = opt
end
parser.on "-x key", "--read-only-profile-key key", "Marks a user profile key as being read-only." do |key|
read_only_profile_keys.push key
end
parser.on "-v verbosity",
"--verbosity level",
"Verbosity level. From 0 to 3. Default: 1" do |v|
Baguette::Context.verbosity = v.to_i
end
parser.on "-h", "--help", "Show this help" do
puts parser
exit 0
end
end
AuthD::Service.new(authd_storage, authd_jwt_key).tap do |authd|
authd.registrations_allowed = authd_registrations
authd.require_email = authd_require_email
authd.mailer_activation_url = activation_url
authd.mailer_field_subject = field_subject
authd.mailer_field_from = field_from
authd.read_only_profile_keys = read_only_profile_keys
end.run
rescue e : OptionParser::Exception
Baguette::Log.error e.message
rescue e
Baguette::Log.error "exception raised: #{e.message}"
e.backtrace.try &.each do |line|
STDERR << " - " << line << '\n'
end
end

View File

@ -1,24 +0,0 @@
require "ipc"
require "ipc/json"
require "./authd.cr"
require "./service.cr" # To load AuthD::Service definition.
class IPC::JSON
def handle(service : AuthD::Service, fd : Int32)
raise "unimplemented"
end
end
module AuthD
class_getter requests = [] of IPC::JSON.class
class_getter responses = [] of IPC::JSON.class
end
class IPC
def schedule(fd, m : (AuthD::Request | AuthD::Response))
schedule fd, m.type.to_u8, m.to_json
end
end
require "./requests/*"
require "./responses/*"

View File

@ -1,108 +0,0 @@
class AuthD::Request
IPC::JSON.message AddUser, 9 do
property login : String
property password : String
property admin : Bool = false
property email : String? = nil
property profile : Hash(String, JSON::Any)? = nil
def initialize(@login, @password, @admin, @email, @profile)
end
def handle(authd : AuthD::Service, fd : Int32)
logged_user = authd.get_logged_user_full? fd
return Response::ErrorMustBeAuthenticated.new if logged_user.nil?
logged_user.assert_permission("authd", "*", User::PermissionLevel::Admin)
if authd.users_per_login.get? @login
return Response::ErrorAlreadyUsedLogin.new
end
# No verification of the user's informations when an admin adds it.
# No mail address verification.
if authd.configuration.require_email && @email.nil?
return Response::ErrorMailRequired.new
end
password_hash = authd.hash_password @password
uid = authd.new_uid
user = User.new uid, @login, password_hash
user.contact.email = @email unless @email.nil?
user.admin = @admin
@profile.try do |profile|
user.profile = profile
end
# We consider adding the user as a registration.
user.date_registration = Time.local
authd.users << user
authd.new_uid_commit uid
Response::UserAdded.new user.to_public
end
end
AuthD.requests << AddUser
IPC::JSON.message BootstrapFirstAdmin, 13 do
property login : String
property password : String
property email : String? = nil
property profile : Hash(String, JSON::Any)? = nil
def initialize(@login, @password, @email, @profile = nil)
end
def handle(authd : AuthD::Service, fd : Int32)
# Check if there already is a registered user.
if authd.users.to_a.size > 0
return Response::ErrorAlreadyUsersInDB.new
end
password_hash = authd.hash_password @password
uid = authd.new_uid
user = User.new uid, @login, password_hash
user.contact.email = @email unless @email.nil?
user.admin = true
@profile.try do |profile|
user.profile = profile
end
# We consider adding the user as a registration.
user.date_registration = Time.local
authd.users << user
authd.new_uid_commit uid
Response::UserAdded.new user.to_public
end
end
AuthD.requests << BootstrapFirstAdmin
IPC::JSON.message DecodeToken, 14 do
property token : String
def initialize(@token)
end
def handle(authd : AuthD::Service, fd : Int32)
logged_user = authd.get_logged_user_full? fd
return Response::ErrorMustBeAuthenticated.new if logged_user.nil?
logged_user.assert_permission("authd", "*", User::PermissionLevel::Read)
token_payload = AuthD::Token.from_s authd.configuration.secret_key, token
user = authd.users_per_uid.get? token_payload.uid.to_s
if user
Response::User.new user.to_public
else
Response::ErrorUserNotFound.new
end
end
end
AuthD.requests << DecodeToken
end

View File

@ -1,34 +0,0 @@
class AuthD::Request
IPC::JSON.message Delete, 8 do
# Deletion can be triggered by either an admin or the related user.
property user : UserID? = nil
def initialize(@user = nil)
end
def handle(authd : AuthD::Service, fd : Int32)
logged_user = authd.get_logged_user_full? fd
return Response::ErrorMustBeAuthenticated.new if logged_user.nil?
user_to_delete = if u = @user
logged_user.assert_permission("authd", "*", User::PermissionLevel::Admin)
authd.user? u
else
logged_user
end
return Response::ErrorUserNotFound.new if user_to_delete.nil?
# User or admin is now verified: let's proceed with the user deletion.
authd.users_per_login.delete user_to_delete.login
# If the current user is deleted, unlog!
if logged_user.uid == user_to_delete.uid
authd.close fd
authd.logged_users.delete fd
end
Response::UserDeleted.new user_to_delete.uid
end
end
AuthD.requests << Delete
end

View File

@ -1,11 +0,0 @@
class AuthD::Request
IPC::JSON.message KeepAlive, 250 do
def initialize()
end
def handle(authd : AuthD::Service, fd : Int32)
Response::KeepAlive.new
end
end
AuthD.requests << KeepAlive
end

View File

@ -1,73 +0,0 @@
require "sodium"
class AuthD::Request
def self.perform_login(authd : AuthD::Service, fd : Int32, user : AuthD::User)
user.date_last_connection = Time.local
token = user.to_token
# Change the date of the last connection.
authd.users_per_uid.update user.uid.to_s, user
# On successuful connection: store the authenticated user in a hash.
authd.logged_users[fd] = user.to_public
Response::Login.new (token.to_s authd.configuration.secret_key), user.uid
end
IPC::JSON.message Login, 0 do
property login : String
property password : String
def initialize(@login, @password)
end
def handle(authd : AuthD::Service, fd : Int32)
begin
user = authd.users_per_login.get @login
rescue e : DODB::MissingEntry
# This lack of proper error message is intentional.
# Let attackers try to authenticate themselves with a wrong login.
return Response::ErrorInvalidCredentials.new
end
# This line is basically just to please the Crystal's type system.
# No user means DODB::MissingEntry, so it's already covered.
return Response::ErrorInvalidCredentials.new if user.nil?
# In case the user hasn't validated his email address,
# authentication shouldn't be possible.
if user.contact.activation_key
return Response::ErrorInvalidCredentials.new
end
pwhash = Sodium::Password::Hash.new
hash = Base64.decode user.password_hash
begin
pwhash.verify hash, @password
rescue
return Response::ErrorInvalidCredentials.new
end
AuthD::Request.perform_login authd, fd, user.not_nil!
end
end
AuthD.requests << Login
IPC::JSON.message AuthByToken, 15 do
property token : String
def initialize(@token)
end
def handle(authd : AuthD::Service, fd : Int32)
token_payload = AuthD::Token.from_s authd.configuration.secret_key, token
user = authd.users_per_uid.get? token_payload.uid.to_s
return Response::ErrorUserNotFound.new if user.nil?
AuthD::Request.perform_login authd, fd, user
end
end
AuthD.requests << AuthByToken
end

View File

@ -1,43 +0,0 @@
class AuthD::Request
IPC::JSON.message ModUser, 6 do
property user : UserID? = nil
property admin : Bool? = nil
property password : String? = nil
property email : String? = nil
def initialize(@user, @admin, @password, @email)
end
def handle(authd : AuthD::Service, fd : Int32)
logged_user = authd.get_logged_user_full? fd
return Response::ErrorMustBeAuthenticated.new if logged_user.nil?
user = if u = @user
logged_user.assert_permission("authd", "*", User::PermissionLevel::Edit)
authd.user? u
else
logged_user
end
return Response::ErrorUserNotFound.new if user.nil?
# Only an admin can uprank or downrank someone.
if admin = @admin
logged_user.assert_permission("authd", "*", User::PermissionLevel::Admin)
user.admin = admin
end
@password.try do |s|
user.password_hash = authd.hash_password s
end
@email.try do |email|
user.contact.email = email
end
authd.users_per_uid.update user.uid.to_s, user
Response::UserEdited.new user.uid
end
end
AuthD.requests << ModUser
end

View File

@ -1,92 +0,0 @@
class AuthD::Request
IPC::JSON.message AskPasswordRecovery, 3 do
property login : String? = nil
property email : String? = nil
def initialize(@login = nil, @email = nil)
end
def handle(authd : AuthD::Service, fd : Int32)
if @login.nil? && @email.nil?
return Response::ErrorUserNotFound.new
end
user = if l = @login
authd.user? l
elsif mail = @email
authd.users_per_email.get? Base64.encode(mail).chomp
else
nil
end
# This is a way for an attacker to know what are the valid logins.
# Not sure I care enough to fix this.
return Response::ErrorUserNotFound.new if user.nil?
# Create a new random key for password renewal.
user.password_renew_key = UUID.random.to_s
authd.users_per_uid.update user.uid.to_s, user
# TODO: this is debug information. Should be removed once tested.
# Once the user is created and stored, we try to contact him
if authd.configuration.print_password_recovery_parameters
pp! user.login,
user.contact.email.not_nil!,
user.password_renew_key.not_nil!
end
mailer_exe = authd.configuration.mailer_exe
template_name = authd.configuration.recovery_template
u_login = user.login
u_email = user.contact.email.not_nil!
u_token = user.password_renew_key.not_nil!
# Once the user is created and stored, we try to contact him.
unless Process.run(mailer_exe,
# PARAMETERS
[ "send", template_name, u_email ],
# ENV
{ "HOME" => "/", "LOGIN" => u_login, "TOKEN" => u_token },
true # clear environment
# input: Process::Redirect::Inherit,
# output: Process::Redirect::Inherit,
# error: Process::Redirect::Inherit
).success?
raise "cannot contact user #{u_login} address #{u_email}"
end
Response::PasswordRecoverySent.new
end
end
AuthD.requests << AskPasswordRecovery
IPC::JSON.message PasswordRecovery, 4 do
property user : UserID
property password_renew_key : String
property new_password : String
def initialize(@user, @password_renew_key, @new_password)
end
def handle(authd : AuthD::Service, fd : Int32)
user = authd.user? @user
# This is a way for an attacker to know what are the valid logins.
# Not sure I care enough to fix this.
return Response::ErrorUserNotFound.new if user.nil?
if user.password_renew_key == @password_renew_key
user.password_hash = authd.hash_password @new_password
else
return Response::ErrorInvalidRenewKey.new
end
user.password_renew_key = nil
authd.users_per_uid.update user.uid.to_s, user
Response::PasswordRecovered.new
end
end
AuthD.requests << PasswordRecovery
end

View File

@ -1,76 +0,0 @@
class AuthD::Request
IPC::JSON.message CheckPermission, 10 do
property user : UserID? = nil
property service : String
property resource : String
def initialize(@user, @service, @resource)
end
def handle(authd : AuthD::Service, fd : Int32)
logged_user = authd.get_logged_user_full? fd
return Response::ErrorMustBeAuthenticated.new if logged_user.nil?
user = if u = @user
logged_user.assert_permission("authd", "*", User::PermissionLevel::Read)
authd.user? u
else
logged_user
end
return Response::ErrorUserNotFound.new if user.nil?
service_permissions = user.permissions[@service]?
resource_permissions = if service_permissions.nil?
User::PermissionLevel::None
elsif p = service_permissions[@resource]?
p
else
User::PermissionLevel::None
end
return Response::PermissionCheck.new @service, @resource, user.uid, resource_permissions
end
end
AuthD.requests << CheckPermission
IPC::JSON.message SetPermission, 11 do
property user : UserID
property service : String
property resource : String
property permission : ::AuthD::User::PermissionLevel
def initialize(@user, @service, @resource, @permission)
end
def handle(authd : AuthD::Service, fd : Int32)
logged_user = authd.get_logged_user_full? fd
return Response::ErrorMustBeAuthenticated.new if logged_user.nil?
logged_user.assert_permission("authd", "*", User::PermissionLevel::Admin)
user = if u = @user
authd.user? u
else
logged_user
end
return Response::ErrorUserNotFound.new if user.nil?
service_permissions = user.permissions[@service]?
if service_permissions.nil?
service_permissions = Hash(String, User::PermissionLevel).new
user.permissions[@service] = service_permissions
end
if @permission.none?
service_permissions.delete @resource
else
service_permissions[@resource] = @permission
end
authd.users_per_uid.update user.uid.to_s, user
Response::PermissionSet.new user.uid, @service, @resource, @permission
end
end
AuthD.requests << SetPermission
end

View File

@ -1,50 +0,0 @@
class AuthD::Request
# Reset elements for which keys are present in `new_profile_entries`.
IPC::JSON.message EditProfileEntries, 7 do
property user : UserID? = nil
property new_profile_entries : Hash(String, JSON::Any)
def initialize(@new_profile_entries, @user = nil)
end
def handle(authd : AuthD::Service, fd : Int32)
logged_user = authd.get_logged_user_full? fd
return Response::ErrorMustBeAuthenticated.new if logged_user.nil?
user = if u = @user
logged_user.assert_permission("authd", "*", User::PermissionLevel::Edit)
authd.user? u
else
logged_user
end
return Response::ErrorUserNotFound.new if user.nil?
new_profile_entries = user.profile || Hash(String, JSON::Any).new
invalid_profile_keys = Array(String).new
unless logged_user.admin
authd.configuration.read_only_profile_keys.each do |key|
if @new_profile_entries.has_key? key
invalid_profile_keys << key
end
end
end
if invalid_profile_keys.size > 0
return Response::ErrorReadOnlyProfileKeys.new invalid_profile_keys
end
@new_profile_entries.each do |key, value|
new_profile_entries[key] = value
end
user.profile = new_profile_entries
authd.users_per_uid.update user.uid.to_s, user
Response::User.new user.to_public
end
end
AuthD.requests << EditProfileEntries
end

View File

@ -1,87 +0,0 @@
class AuthD::Request
IPC::JSON.message Register, 1 do
property login : String
property password : String
property email : String? = nil
property profile : Hash(String, JSON::Any)? = nil
def initialize(@login, @password, @email, @profile)
end
def handle(authd : AuthD::Service, fd : Int32)
unless authd.configuration.registrations
return Response::ErrorRegistrationsClosed.new
end
if authd.users_per_login.get? @login
return Response::ErrorAlreadyUsedLogin.new
end
acceptable_login_regex = "[a-zA-Z][-_ a-zA-Z0-9']+"
pattern = Regex.new acceptable_login_regex, Regex::Options::IGNORE_CASE
return Response::ErrorInvalidLoginFormat.new unless pattern =~ @login
if authd.configuration.require_email && @email.nil?
return Response::ErrorMailRequired.new
end
if ! @email.nil?
# Test on the email address format.
grok = Grok.new [ "%{EMAILADDRESS:email}" ]
result = grok.parse @email.not_nil!
email = result["email"]?
return Response::ErrorInvalidEmailFormat.new if email.nil?
end
# In this case we should not accept its registration.
return Response::ErrorPasswordTooShort.new if @password.size < 20
return Response::ErrorPasswordTooLong.new if @password.size > 100
uid = authd.new_uid
password = authd.hash_password @password
user = User.new uid, @login, password
user.contact.email = @email unless @email.nil?
user.contact.new_activation_key
@profile.try do |profile|
user.profile = profile
end
user.date_registration = Time.local
begin
mailer_exe = authd.configuration.mailer_exe
template_name = authd.configuration.activation_template
u_login = user.login
u_email = user.contact.email.not_nil!
u_activation_key = user.contact.activation_key.not_nil!
# Once the user is created and stored, we try to contact him.
unless Process.run(mailer_exe,
# PARAMETERS
[ "send", template_name, u_email ],
# ENV
{ "HOME" => "/", "LOGIN" => u_login, "TOKEN" => u_activation_key },
true # clear environment
# input: Process::Redirect::Inherit,
# output: Process::Redirect::Inherit,
# error: Process::Redirect::Inherit
).success?
raise "cannot contact user #{u_login} address #{u_email}"
end
rescue e
Baguette::Log.error "mailer: #{e}"
return Response::ErrorCannotContactUser.new
end
# add the user only if we were able to send the confirmation mail
authd.users << user
authd.new_uid_commit uid
Response::UserAdded.new user.to_public
end
end
AuthD.requests << Register
end

View File

@ -1,52 +0,0 @@
class AuthD::Request
IPC::JSON.message SearchUser, 12 do
property regex : String? = nil
# Since the list could be long, here is a way to get it at a reasonable pace.
property offset : Int32 = 0
# By default, authd will send 10 users at a time.
def initialize(@regex = nil, @offset = 0)
end
def handle(authd : AuthD::Service, fd : Int32)
logged_user = authd.get_logged_user_full? fd
return Response::ErrorMustBeAuthenticated.new if logged_user.nil?
logged_user.assert_permission("authd", "*", User::PermissionLevel::Read)
users = authd.users.to_a
matching_users = Array(AuthD::User::Public).new
# FIXME: could be optimized.
result = if regex = @regex
pattern = Regex.new regex, Regex::Options::IGNORE_CASE
users.each do |u|
if pattern =~ u.login || u.profile.try do |profile|
full_name = profile["full_name"]?
if full_name.nil?
false
else
pattern =~ full_name.as_s
end
end || u.contact.email.try do |email|
pattern =~ email
end
Baguette::Log.debug "#{u.login} matches #{pattern}"
matching_users << u.to_public
else
Baguette::Log.debug "#{u.login} doesn't match #{pattern}"
end
end
matching_users[offset..offset+10]
else
users[offset..offset+10].each do |u|
matching_users << u.to_public
end
matching_users
end
Response::MatchingUsers.new result
end
end
AuthD.requests << SearchUser
end

View File

@ -1,52 +0,0 @@
class AuthD::Request
IPC::JSON.message ValidateUser, 2 do
property user : UserID
property activation_key : String
def initialize(@user, @activation_key)
end
def handle(authd : AuthD::Service, fd : Int32)
user = authd.user? @user
# This is a way for an attacker to know what are the valid logins.
# Not sure I care enough to fix this.
return Response::ErrorUserNotFound.new if user.nil?
if user.contact.activation_key.nil?
return Response::ErrorUserAlreadyValidated.new
end
# Remove the user contact activation key: the email is validated.
if user.contact.activation_key == @activation_key
user.contact.activation_key = nil
else
return Response::ErrorInvalidActivationKey.new
end
authd.users_per_uid.update user.uid.to_s, user
Response::UserValidated.new user.to_public
end
end
AuthD.requests << ValidateUser
IPC::JSON.message GetUser, 5 do
property user : UserID
def initialize(@user)
end
def handle(authd : AuthD::Service, fd : Int32)
logged_user = authd.get_logged_user? fd
return Response::ErrorMustBeAuthenticated.new if logged_user.nil?
user = authd.user? @user
# This is a way for an attacker to know what are the valid logins.
# Not sure I care enough to fix this.
return Response::ErrorUserNotFound.new if user.nil?
Response::User.new user.to_public
end
end
AuthD.requests << GetUser
end

View File

@ -1,9 +0,0 @@
class AuthD::Response
IPC::JSON.message Contacts, 12 do
property user : UInt32
property email : String? = nil
def initialize(@user, @email)
end
end
AuthD.responses << Contacts
end

View File

@ -1,105 +0,0 @@
class AuthD::Response
IPC::JSON.message Error, 0 do
property reason : String? = nil
def initialize(@reason)
end
end
AuthD.responses << Error
IPC::JSON.message ErrorMustBeAuthenticated, 20 do
def initialize()
end
end
AuthD.responses << ErrorMustBeAuthenticated
IPC::JSON.message ErrorAlreadyUsedLogin, 21 do
def initialize()
end
end
AuthD.responses << ErrorAlreadyUsedLogin
IPC::JSON.message ErrorMailRequired, 22 do
def initialize()
end
end
AuthD.responses << ErrorMailRequired
IPC::JSON.message ErrorUserNotFound, 23 do
def initialize()
end
end
AuthD.responses << ErrorUserNotFound
IPC::JSON.message ErrorPasswordTooShort, 24 do
def initialize()
end
end
AuthD.responses << ErrorPasswordTooShort
IPC::JSON.message ErrorInvalidCredentials, 25 do
def initialize()
end
end
AuthD.responses << ErrorInvalidCredentials
IPC::JSON.message ErrorRegistrationsClosed, 26 do
def initialize()
end
end
AuthD.responses << ErrorRegistrationsClosed
IPC::JSON.message ErrorInvalidLoginFormat, 27 do
def initialize()
end
end
AuthD.responses << ErrorInvalidLoginFormat
IPC::JSON.message ErrorInvalidEmailFormat, 28 do
def initialize()
end
end
AuthD.responses << ErrorInvalidEmailFormat
IPC::JSON.message ErrorAlreadyUsersInDB, 29 do
def initialize()
end
end
AuthD.responses << ErrorAlreadyUsersInDB
IPC::JSON.message ErrorReadOnlyProfileKeys, 30 do
property read_only_keys : Array(String)
def initialize(@read_only_keys)
end
end
AuthD.responses << ErrorReadOnlyProfileKeys
IPC::JSON.message ErrorInvalidActivationKey, 31 do
def initialize()
end
end
AuthD.responses << ErrorInvalidActivationKey
IPC::JSON.message ErrorUserAlreadyValidated, 32 do
def initialize()
end
end
AuthD.responses << ErrorUserAlreadyValidated
IPC::JSON.message ErrorCannotContactUser, 33 do
def initialize()
end
end
AuthD.responses << ErrorCannotContactUser
IPC::JSON.message ErrorInvalidRenewKey, 34 do
def initialize()
end
end
AuthD.responses << ErrorInvalidRenewKey
IPC::JSON.message ErrorPasswordTooLong, 35 do
def initialize()
end
end
AuthD.responses << ErrorPasswordTooLong
end

View File

@ -1,7 +0,0 @@
class AuthD::Response
IPC::JSON.message KeepAlive, 250 do
def initialize()
end
end
AuthD.responses << KeepAlive
end

View File

@ -1,9 +0,0 @@
class AuthD::Response
IPC::JSON.message Login, 1 do
property uid : UInt32
property token : String
def initialize(@token, @uid)
end
end
AuthD.responses << Login
end

View File

@ -1,13 +0,0 @@
class AuthD::Response
IPC::JSON.message PasswordRecoverySent, 9 do
def initialize
end
end
AuthD.responses << PasswordRecoverySent
IPC::JSON.message PasswordRecovered, 10 do
def initialize
end
end
AuthD.responses << PasswordRecovered
end

View File

@ -1,21 +0,0 @@
class AuthD::Response
IPC::JSON.message PermissionCheck, 7 do
property user : UInt32
property service : String
property resource : String
property permission : ::AuthD::User::PermissionLevel
def initialize(@service, @resource, @user, @permission)
end
end
AuthD.responses << PermissionCheck
IPC::JSON.message PermissionSet, 8 do
property user : UInt32
property service : String
property resource : String
property permission : ::AuthD::User::PermissionLevel
def initialize(@user, @service, @resource, @permission)
end
end
AuthD.responses << PermissionSet
end

View File

@ -1,50 +0,0 @@
class AuthD::Response
IPC::JSON.message User, 2 do
property user : ::AuthD::User::Public
def initialize(@user)
end
end
AuthD.responses << User
IPC::JSON.message UserAdded, 3 do
property user : ::AuthD::User::Public
def initialize(@user)
end
end
AuthD.responses << UserAdded
IPC::JSON.message UserEdited, 4 do
property uid : UInt32
def initialize(@uid)
end
end
AuthD.responses << UserEdited
IPC::JSON.message UserValidated, 5 do
property user : ::AuthD::User::Public
def initialize(@user)
end
end
AuthD.responses << UserValidated
IPC::JSON.message UsersList, 6 do
property users : Array(::AuthD::User::Public)
def initialize(@users)
end
end
AuthD.responses << UsersList
IPC::JSON.message MatchingUsers, 11 do
property users : Array(::AuthD::User::Public)
def initialize(@users)
end
end
AuthD.responses << MatchingUsers
IPC::JSON.message UserDeleted, 12 do
property uid : UInt32
def initialize(@uid)
end
end
AuthD.responses << UserDeleted
end

View File

@ -1,76 +0,0 @@
require "./service.cr"
begin
simulation, no_configuration, configuration_file = Baguette::Configuration.option_parser
configuration = if no_configuration
Baguette::Log.info "do not load a configuration file."
Baguette::Configuration::Auth.new
else
Baguette::Configuration::Auth.get(configuration_file) ||
Baguette::Configuration::Auth.new
end
Baguette::Context.verbosity = configuration.verbosity
if key_file = configuration.secret_key_file
configuration.secret_key = File.read(key_file).chomp
end
OptionParser.parse do |parser|
parser.banner = "usage: authd [options]"
parser.on "--storage directory", "Directory in which to store users." do |directory|
configuration.storage = directory
end
parser.on "-k file", "--key-file file", "JWT key file" do |file_name|
configuration.secret_key = File.read(file_name).chomp
end
parser.on "-R", "--allow-registrations", "Allow user registration." do
configuration.registrations = true
end
parser.on "-E", "--require-email", "Require an email." do
configuration.require_email = true
end
parser.on "-t activation-template-name", "--activation-template name", "Email activation template." do |opt|
configuration.activation_template = opt
end
parser.on "-r recovery-template-name", "--recovery-template name", "Email recovery template." do |opt|
configuration.recovery_template = opt
end
parser.on "-m mailer-exe", "--mailer mailer-exe", "Application to send registration emails." do |opt|
configuration.mailer_exe = opt
end
parser.on "-x key", "--read-only-profile-key key", "Marks a user profile key as being read-only." do |key|
configuration.read_only_profile_keys.push key
end
parser.on "-h", "--help", "Show this help" do
puts parser
exit 0
end
end
if simulation
pp! configuration
exit 0
end
AuthD::Service.new(configuration).run
rescue e : OptionParser::Exception
Baguette::Log.error e.message
rescue e
Baguette::Log.error "exception raised: #{e.message}"
e.backtrace.try &.each do |line|
STDERR << " - " << line << '\n'
end
end

View File

@ -1,215 +0,0 @@
require "./authd.cr"
require "sodium"
extend AuthD
class Baguette::Configuration
class Auth < IPC
property recreate_indexes : Bool = false
property storage : String = "storage"
property registrations : Bool = false
property require_email : Bool = false
property activation_template : String = "email-activation"
property recovery_template : String = "email-recovery"
property mailer_exe : String = "mailer"
property read_only_profile_keys : Array(String) = Array(String).new
property print_password_recovery_parameters : Bool = false
end
end
# Provides a JWT-based authentication scheme for service-specific users.
class AuthD::Service < IPC
property configuration : Baguette::Configuration::Auth
# DB and its indexes.
property users : DODB::DataBase(User)
property users_per_uid : DODB::Index(User)
property users_per_login : DODB::Index(User)
property users_per_email : DODB::Index(User)
property logged_users : Hash(Int32, AuthD::User::Public)
# #{@configuration.storage}/last_used_uid
property last_uid_file : String
def initialize(@configuration)
super()
@users = DODB::DataBase(User).new @configuration.storage
@users_per_uid = @users.new_index "uid", &.uid.to_s
@users_per_login = @users.new_index "login", &.login
@users_per_email = @users.new_index "email" do |user|
if mail = user.contact.email
Base64.encode(mail).chomp
else
""
end
end
@last_uid_file = "#{@configuration.storage}/last_used_uid"
@logged_users = Hash(Int32, AuthD::User::Public).new
if @configuration.recreate_indexes
Baguette::Log.info "Recreate indexes"
@users.reindex_everything!
end
self.timer @configuration.ipc_timer
self.service_init "auth"
end
def obsolete_hash_password(password : String) : String
digest = OpenSSL::Digest.new "sha256"
digest << password
digest.hexfinal
end
def hash_password(password : String) : String
pwhash = Sodium::Password::Hash.new
hash = pwhash.create password
pwhash.verify hash, password
Base64.strict_encode hash
end
# new_uid reads the last given UID and returns it incremented.
# Splitting the retrieval and record of new user ids allows to
# only increment when an user fully registers, thus avoiding a
# Denial of Service attack.
#
# WARNING: to record this new UID, new_uid_commit must be called.
# WARNING: new_uid isn't thread safe.
def new_uid : UInt32
uid : UInt32 = begin
File.read(@last_uid_file).to_u32
rescue
999.to_u32
end
uid += 1
end
# new_uid_commit records the new UID.
# WARNING: new_uid_commit isn't thread safe.
def new_uid_commit(uid : Int)
File.write @last_uid_file, uid.to_s
end
def get_logged_user?(fd : Int32)
@logged_users[fd]?
end
# Instead of just getting the public view of a logged user,
# get the actual User instance.
def get_logged_user_full?(fd : Int32)
if u = @logged_users[fd]?
user? u.uid
end
end
def user?(uid_or_login : UserID)
if uid_or_login.is_a? UInt32
@users_per_uid.get? uid_or_login.to_s
else
@users_per_login.get? uid_or_login
end
end
def handle_request(event : IPC::Event)
request_start = Time.utc
array = event.message.not_nil!
slice = Slice.new array.to_unsafe, array.size
message = IPCMessage::TypedMessage.deserialize slice
request = AuthD.requests.parse_ipc_json message.not_nil!
if request.nil?
raise "unknown request type"
end
request_name = request.class.name.sub /^AuthD::Request::/, ""
response = begin
request.handle self, event.fd
rescue e : UserNotFound
Baguette::Log.error "(fd #{ "%4d" % event.fd}) #{request_name} user not found"
AuthD::Response::Error.new "authorization error"
rescue e : AuthenticationInfoLacking
Baguette::Log.error "(fd #{ "%4d" % event.fd}) #{request_name} lacking authentication info"
AuthD::Response::Error.new "authorization error"
rescue e : AdminAuthorizationException
Baguette::Log.error "(fd #{ "%4d" % event.fd}) #{request_name} admin authentication failed"
AuthD::Response::Error.new "authorization error"
rescue e
Baguette::Log.error "(fd #{ "%4d" % event.fd}) #{request_name} generic error #{e}"
AuthD::Response::Error.new "unknown error"
end
# If clients sent requests with an “id” field, it is copied
# in the responses. Allows identifying responses easily.
response.id = request.id
schedule event.fd, response
duration = Time.utc - request_start
response_name = response.class.name.sub /^AuthD::Response::/, ""
if response.is_a? AuthD::Response::Error
Baguette::Log.warning "fd #{ "%4d" % event.fd} (#{duration}) #{request_name} >> #{response_name} (#{response.reason})"
else
if request_name != "KeepAlive" || @configuration.print_keepalive
Baguette::Log.debug "fd #{ "%4d" % event.fd} (#{duration}) #{request_name} >> #{response_name}"
end
end
end
def get_user_from_token(token : String)
token_payload = Token.from_s(@configuration.secret_key, token)
@users_per_uid.get? token_payload.uid.to_s
end
def run
Baguette::Log.title "Starting authd"
Baguette::Log.info "(mailer) Email activation template: #{@configuration.activation_template}"
Baguette::Log.info "(mailer) Email recovery template: #{@configuration.recovery_template}"
self.loop do |event|
case event.type
when LibIPC::EventType::Timer
Baguette::Log.debug "Timer" if @configuration.print_ipc_timer
when LibIPC::EventType::MessageRx
Baguette::Log.debug "Received message from #{event.fd}" if @configuration.print_ipc_message_received
begin
handle_request event
rescue e
Baguette::Log.error "#{e.message}"
# send event.fd, Response::Error.new e.message
end
when LibIPC::EventType::MessageTx
Baguette::Log.debug "Message sent to #{event.fd}" if @configuration.print_ipc_message_sent
when LibIPC::EventType::Connection
Baguette::Log.debug "Connection from #{event.fd}" if @configuration.print_ipc_connection
when LibIPC::EventType::Disconnection
Baguette::Log.debug "Disconnection from #{event.fd}" if @configuration.print_ipc_disconnection
@logged_users.delete event.fd
else
Baguette::Log.error "Not implemented behavior for event: #{event}"
if event.responds_to?(:fd)
fd = event.fd
Baguette::Log.warning "closing #{fd}"
close fd
@logged_users.delete fd
end
end
end
end
end

View File

@ -4,7 +4,7 @@ class AuthD::Token
include JSON::Serializable include JSON::Serializable
property login : String property login : String
property uid : UInt32 property uid : Int32
def initialize(@login, @uid) def initialize(@login, @uid)
end end
@ -23,7 +23,7 @@ class AuthD::Token
def self.from_s(key, str) def self.from_s(key, str)
payload, meta = JWT.decode str, key, JWT::Algorithm::HS256 payload, meta = JWT.decode str, key, JWT::Algorithm::HS256
self.new payload["login"].as_s, payload["uid"].as_i64.to_u32 self.new payload["login"].as_s, payload["uid"].as_i
end end
end end

View File

@ -1,6 +1,9 @@
require "json" require "json"
require "uuid" require "uuid"
require "./token.cr"
class AuthD::User class AuthD::User
include JSON::Serializable include JSON::Serializable
@ -19,21 +22,18 @@ class AuthD::User
include JSON::Serializable include JSON::Serializable
# the activation key is removed once the user is validated # the activation key is removed once the user is validated
property activation_key : String? = nil property activation_key : String?
property email : String? property email : String?
property phone : String?
def initialize(@email = nil) def initialize(@email = nil, @phone = nil)
end
def new_activation_key
@activation_key = UUID.random.to_s @activation_key = UUID.random.to_s
end end
end end
# Public. # Public.
property login : String property login : String
property uid : UInt32 property uid : Int32
property admin : Bool = false
property profile : Hash(String, JSON::Any)? property profile : Hash(String, JSON::Any)?
# Private. # Private.
@ -50,7 +50,7 @@ class AuthD::User
Token.new @login, @uid Token.new @login, @uid
end end
def initialize(@uid, @login, @password_hash, @admin = false) def initialize(@uid, @login, @password_hash)
@contact = Contact.new @contact = Contact.new
@permissions = Hash(String, Hash(String, PermissionLevel)).new @permissions = Hash(String, Hash(String, PermissionLevel)).new
@configuration = Hash(String, Hash(String, JSON::Any)).new @configuration = Hash(String, Hash(String, JSON::Any)).new
@ -60,35 +60,17 @@ class AuthD::User
include JSON::Serializable include JSON::Serializable
property login : String property login : String
property uid : UInt32 property uid : Int32
property admin : Bool = false
property profile : Hash(String, JSON::Any)? property profile : Hash(String, JSON::Any)?
property date_registration : Time? property date_registration : Time?
def initialize(@uid, @login, @admin, @profile, @date_registration) def initialize(@uid, @login, @profile, @date_registration)
end end
end end
def to_public : Public def to_public : Public
Public.new @uid, @login, @admin, @profile, @date_registration Public.new @uid, @login, @profile, @date_registration
end
def assert_permission(service : String, resource : String, level : User::PermissionLevel)
return if @admin # skip if admin
permissions = @permissions[service]?
unless permissions
raise AdminAuthorizationException.new "unauthorized (not admin nor in #{service} group)"
end
rights = permissions[resource]?
unless rights
raise AdminAuthorizationException.new "unauthorized (no rights on '#{service}/#{resource}')"
end
if rights < level
raise AdminAuthorizationException.new "unauthorized (insufficient rights on '#{service}/#{resource}')"
end
end end
end end

View File

@ -1,12 +1,23 @@
require "option_parser" require "option_parser"
require "ipc"
require "yaml" require "yaml"
require "./authd.cr"
require "baguette-crystal-base"
require "../src/authd.cr"
# require "./altideal-client.cr"
# require "./yaml_uuid.cr" # YAML UUID parser
# require "./authd_api.cr" # Authd interface functions
class Context class Context
class_property simulation = false # do not perform the action class_property simulation = false # do not perform the action
class_property authd_login : String? = nil class_property authd_login = "undef" # undef authd user
class_property authd_pass : String? = nil class_property authd_pass = "undef" # undef authd user password
class_property shared_key = "undef" # undef authd user password
# # Properties to select what to display when printing a deal. # # Properties to select what to display when printing a deal.
# class_property print_title = true # class_property print_title = true
@ -17,12 +28,14 @@ class Context
class_property command = "not-implemented" class_property command = "not-implemented"
class_property user_profile : Hash(String,JSON::Any)? class_property user_profile : Hash(String,JSON::Any)?
class_property phone : String?
class_property email : String? class_property email : String?
# Will be parsed later, with a specific parser. # Will be parsed later, with a specific parser.
class_property args : Array(String)? = nil class_property args : Array(String)? = nil
end end
# require "./parse-me"
require "./better-parser" require "./better-parser"
class Actions class Actions
@ -51,18 +64,14 @@ class Actions
property authd : AuthD::Client property authd : AuthD::Client
def initialize(@authd) def initialize(@authd)
@the_call["user-registration"] = ->user_registration
@the_call["user-validation"] = ->user_validation # Do not require authentication.
@the_call["user-recovery"] = ->user_recovery # Do not require authentication.
@the_call["user-delete"] = ->user_deletion # Do not require admin priviledges.
@the_call["user-get"] = ->user_get
@the_call["user-search"] = ->user_search
@the_call["bootstrap"] = ->bootstrap
# Require admin privileges.
@the_call["user-add"] = ->user_add @the_call["user-add"] = ->user_add
@the_call["user-mod"] = ->user_mod @the_call["user-mod"] = ->user_mod
@the_call["user-registration"] = ->user_registration # Do not require admin priviledges.
@the_call["user-delete"] = ->user_deletion # Do not require admin priviledges.
@the_call["user-get"] = ->user_get # Do not require authentication.
@the_call["user-validation"] = ->user_validation # Do not require authentication.
@the_call["user-recovery"] = ->user_recovery # Do not require authentication.
@the_call["user-search"] = ->user_search # Do not require authentication.
@the_call["permission-set"] = ->permission_set @the_call["permission-set"] = ->permission_set
@the_call["permission-check"] = ->permission_check @the_call["permission-check"] = ->permission_check
@ -74,76 +83,33 @@ class Actions
# #
def user_add def user_add
puts "User add!!!"
args = Context.args.not_nil! args = Context.args.not_nil!
login, email = args[0..1] login, email, phone = args[0..2]
profile = Context.user_profile profile = Context.user_profile
password = Actions.ask_password password = Actions.ask_password
exit 1 unless password exit 1 unless password
# By default: not admin. pp! authd.add_user login, password.not_nil!, email, phone, profile: profile
pp! authd.add_user login, password.not_nil!, false, email, profile: profile
rescue e : AuthD::Exception rescue e : AuthD::Exception
puts "error: #{e.message}" puts "error: #{e.message}"
end end
def user_registration def user_registration
args = Context.args.not_nil! args = Context.args.not_nil!
login, email = args[0..1] login, email, phone = args[0..2]
profile = Context.user_profile
password = Actions.ask_password
unless password
Baguette::Log.error "no password!"
exit 1
end
res = authd.register login, password.not_nil!, email, profile: profile
case res
when Response::UserAdded
Baguette::Log.info "user registered, mail sent"
exit 0
when Response::ErrorRegistrationsClosed
Baguette::Log.error "registrations are closed (only admins can add users)"
exit 1
when Response::ErrorAlreadyUsedLogin
Baguette::Log.error "login already used"
exit 1
when Response::ErrorMailRequired
Baguette::Log.error "an email address is required"
exit 1
when Response::ErrorInvalidEmailFormat
Baguette::Log.error "provided email address has an invalid format"
exit 1
when Response::ErrorCannotContactUser
Baguette::Log.error "an error occured while contacting the user with this email address"
exit 1
when Response::ErrorInvalidLoginFormat
Baguette::Log.error "invalid login"
exit 1
when Response::ErrorPasswordTooShort
Baguette::Log.error "password too short"
exit 1
end
rescue e
puts "error: #{e.message}"
end
def bootstrap
puts "Bootstrap"
args = Context.args.not_nil!
login, email = args[0..1]
profile = Context.user_profile profile = Context.user_profile
password = Actions.ask_password password = Actions.ask_password
exit 1 unless password exit 1 unless password
pp! authd.bootstrap login, password.not_nil!, email, profile res = authd.register login, password.not_nil!, email, phone, profile: profile
puts res
rescue e : AuthD::Exception rescue e : AuthD::Exception
puts "error: #{e.message}" puts "error: #{e.message}"
end end
# TODO # TODO
def user_mod def user_mod
args = Context.args.not_nil! args = Context.args.not_nil!
@ -162,19 +128,25 @@ class Actions
end end
email = Context.email email = Context.email
phone = Context.phone
Baguette::Log.error "This function shouldn't be used for now." Baguette::Log.error "This function shouldn't be used for now."
Baguette::Log.error "It is way too cumbersome." Baguette::Log.error "It is way too cumbersome."
# res = authd.add_user login, password, email, profile: profile # res = authd.add_user login, password, email, phone, profile: profile
# puts res # puts res
end end
def user_deletion def user_deletion
args = Context.args.not_nil! args = Context.args.not_nil!
userid = args[0].to_u32 userid = args[0].to_i
res = authd.delete userid # Check if the request comes from an admin or the user.
res = if Context.shared_key.nil?
authd.delete userid, Context.authd_login, Context.authd_pass
else
authd.delete userid, Context.shared_key
end
puts res puts res
end end
@ -196,37 +168,27 @@ class Actions
end end
def user_recovery def user_recovery
args = Context.args.not_nil! args = Context.args.not_nil!
login = args[0] login, email = args[0..1]
pp! authd.ask_password_recovery login pp! authd.ask_password_recovery login, email
end end
def permission_check def permission_check
args = Context.args.not_nil! args = Context.args.not_nil!
user, application, resource = args[0..2] user, application, resource = args[0..2]
res = @authd.check_permission user.to_u32, application, resource # pp! user, application, resource
case res
when Response::PermissionCheck res = @authd.check_permission user.to_i, application, resource
s = res.service puts res
r = res.resource
u = res.user
p = res.permission
Baguette::Log.info "app #{s} resource #{r} user #{u}: #{p}"
end
end end
def permission_set def permission_set
args = Context.args.not_nil! args = Context.args.not_nil!
user, application, resource, permission = args[0..3] user, application, resource, permission = args[0..3]
# pp! user, application, resource, permission
perm = AuthD::User::PermissionLevel.parse(permission) perm = AuthD::User::PermissionLevel.parse(permission)
res = @authd.set_permission user.to_u32, application, resource, perm res = @authd.set_permission user.to_i, application, resource, perm
case res puts res
when Response::PermissionSet
s = res.service
r = res.resource
u = res.user
p = res.permission
Baguette::Log.info "app #{s} resource #{r} user #{u}: #{p}"
end
end end
end end
@ -234,25 +196,18 @@ def main
# Authd connection. # Authd connection.
authd = AuthD::Client.new authd = AuthD::Client.new
authd.key = Context.shared_key if Context.shared_key != "undef"
if login = Context.authd_login # Authd token.
pass = if p = Context.authd_pass # FIXME: not sure about getting the token, it seems not used elsewhere.
p # If login == pass == "undef": do not even try.
else #unless Context.authd_login == Context.authd_pass && Context.authd_login == "undef"
password = Actions.ask_password # login = Context.authd_login
raise "cannot get a password" unless password # pass = Context.authd_pass
password # token = authd.get_token? login, pass
end # raise "cannot get a token" if token.nil?
response = authd.login? login, pass # # authd.login token
case response #end
when Response::Login
uid = response.uid
token = response.token
Baguette::Log.info "Authenticated as #{login} #{uid}, token: #{token}"
else
raise "Cannot authenticate to authd with login #{login}: #{response}."
end
end
actions = Actions.new authd actions = Actions.new authd
@ -261,8 +216,6 @@ def main
actions.the_call[Context.command].call actions.the_call[Context.command].call
rescue e rescue e
Baguette::Log.info "The command is not recognized (or implemented)." Baguette::Log.info "The command is not recognized (or implemented)."
Baguette::Log.info "Exception: #{e}."
pp! e
end end
# authd disconnection # authd disconnection
@ -276,3 +229,4 @@ end
# tool [options] command [options-for-command] # tool [options] command [options-for-command]
main main

View File

@ -1,5 +1,12 @@
require "option_parser" require "option_parser"
opt_authd_admin = -> (parser : OptionParser) {
parser.on "-k file", "--key-file file", "Read the authd shared key from a file." do |file|
Context.shared_key = File.read(file).chomp
Baguette::Log.info "Key for admin operations: #{Context.shared_key}."
end
}
# frequently used functions # frequently used functions
opt_authd_login = -> (parser : OptionParser) { opt_authd_login = -> (parser : OptionParser) {
parser.on "-l LOGIN", "--login LOGIN", "Authd user login." do |login| parser.on "-l LOGIN", "--login LOGIN", "Authd user login." do |login|
@ -26,6 +33,13 @@ opt_profile = -> (parser : OptionParser) {
end end
} }
opt_phone = -> (parser : OptionParser) {
parser.on "-n phone", "--phone-number num", "Phone number." do |phone|
Context.phone = phone
Baguette::Log.info "Reading the user phone number: #{Context.phone}."
end
}
opt_email = -> (parser : OptionParser) { opt_email = -> (parser : OptionParser) {
parser.on "-e email", "--email address", "Email address." do |email| parser.on "-e email", "--email address", "Email address." do |email|
Context.email = email Context.email = email
@ -35,15 +49,13 @@ opt_email = -> (parser : OptionParser) {
# Unrecognized parameters are used to create commands with multiple arguments. # Unrecognized parameters are used to create commands with multiple arguments.
# Example: user add login email # Example: user add _login email phone_
# Here, login and email are unrecognized arguments. # Here, login, email and phone are unrecognized arguments.
# Still, the "user add" command expect them. # Still, the "user add" command expect them.
unrecognized_args_to_context_args = -> (parser : OptionParser, n_expected_args : Int32) { unrecognized_args_to_context_args = -> (parser : OptionParser, n_expected_args : Int32) {
# With the right args, these will be interpreted as serialized data. # With the right args, these will be interpreted as serialized data.
parser.unknown_args do |args| parser.unknown_args do |args|
if args.size != n_expected_args if args.size != n_expected_args
Baguette::Log.error "expected number of arguments: #{n_expected_args}, received: #{args.size}"
Baguette::Log.error "args: #{args}"
Baguette::Log.error "#{parser}" Baguette::Log.error "#{parser}"
exit 1 exit 1
end end
@ -69,36 +81,29 @@ parser = OptionParser.new do |parser|
exit 0 exit 0
end end
parser.on "bootstrap", "Add the first user (an admin)." do
parser.banner = "Usage: bootstrap login email [-P profile]"
Baguette::Log.info "Bootstrapping the first user (admin) to the DB."
Context.command = "bootstrap"
opt_profile.call parser
opt_help.call parser
# login email
unrecognized_args_to_context_args.call parser, 2
end
parser.on "user", "Operations on users." do parser.on "user", "Operations on users." do
parser.banner = "Usage: user [add | mod | delete | validate | search | get | recover | register ]" parser.banner = "Usage: user [add | mod | delete | validate | search | get | recover | register ]"
parser.on "add", "Adding a user to the DB." do parser.on "add", "Adding a user to the DB." do
parser.banner = "usage: user add login email [-P profile] [opt]" parser.banner = "usage: user add login email phone [-P profile] [opt]"
Baguette::Log.info "Adding a user to the DB." Baguette::Log.info "Adding a user to the DB."
Context.command = "user-add" Context.command = "user-add"
opt_authd_login.call parser opt_authd_admin.call parser
opt_profile.call parser opt_profile.call parser
opt_email.call parser
opt_phone.call parser
opt_help.call parser opt_help.call parser
# login email # login email phone
unrecognized_args_to_context_args.call parser, 2 unrecognized_args_to_context_args.call parser, 3
end end
parser.on "mod", "Modify a user account." do parser.on "mod", "Modify a user account." do
parser.banner = "Usage: user mod userid [-e email|-P profile] [opt]" parser.banner = "Usage: user mod userid [-e email|-n phone|-P profile] [opt]"
Baguette::Log.info "Modify a user account." Baguette::Log.info "Modify a user account."
Context.command = "user-mod" Context.command = "user-mod"
opt_authd_login.call parser opt_authd_admin.call parser
opt_email.call parser opt_email.call parser
opt_phone.call parser
opt_profile.call parser opt_profile.call parser
opt_help.call parser opt_help.call parser
# userid # userid
@ -111,6 +116,7 @@ parser = OptionParser.new do |parser|
Context.command = "user-delete" Context.command = "user-delete"
# You can either be the owner of the account, or an admin. # You can either be the owner of the account, or an admin.
opt_authd_login.call parser opt_authd_login.call parser
opt_authd_admin.call parser
opt_help.call parser opt_help.call parser
# userid # userid
unrecognized_args_to_context_args.call parser, 1 unrecognized_args_to_context_args.call parser, 1
@ -119,7 +125,7 @@ parser = OptionParser.new do |parser|
parser.on "validate", "Validate user." do parser.on "validate", "Validate user." do
parser.banner = "Usage: user validate login activation-key [opt]" parser.banner = "Usage: user validate login activation-key [opt]"
Baguette::Log.info "Validate user." Baguette::Log.info "Validate user."
Context.command = "user-validation" Context.command = "user-validate"
# No need to be authenticated. # No need to be authenticated.
opt_help.call parser opt_help.call parser
# login activation-key # login activation-key
@ -130,7 +136,7 @@ parser = OptionParser.new do |parser|
parser.banner = "Usage: user get login [opt]" parser.banner = "Usage: user get login [opt]"
Baguette::Log.info "Get user info." Baguette::Log.info "Get user info."
Context.command = "user-get" Context.command = "user-get"
opt_authd_login.call parser # No need to be authenticated.
opt_help.call parser opt_help.call parser
# login # login
unrecognized_args_to_context_args.call parser, 1 unrecognized_args_to_context_args.call parser, 1
@ -140,32 +146,36 @@ parser = OptionParser.new do |parser|
parser.banner = "Usage: user recover login [opt]" parser.banner = "Usage: user recover login [opt]"
Baguette::Log.info "Search user." Baguette::Log.info "Search user."
Context.command = "user-search" Context.command = "user-search"
opt_authd_login.call parser # No need to be authenticated.
opt_help.call parser opt_help.call parser
# login # login
unrecognized_args_to_context_args.call parser, 1 unrecognized_args_to_context_args.call parser, 1
end end
parser.on "recover", "Recover user password." do parser.on "recover", "Recover user password." do
parser.banner = "Usage: user recover login [opt]" parser.banner = "Usage: user recover login email [opt]"
Baguette::Log.info "Recover user password." Baguette::Log.info "Recover user password."
Context.command = "user-recovery" Context.command = "user-recovery"
# No need to be authenticated. # No need to be authenticated.
opt_help.call parser opt_help.call parser
# login email # login email
unrecognized_args_to_context_args.call parser, 1 unrecognized_args_to_context_args.call parser, 2
end end
# Do not require to be admin. # Do not require to be admin.
parser.on "register", "Register a user (requires activation)." do parser.on "register", "Register a user (requires activation)." do
parser.banner = "Usage: user register login email [-P profile] [opt]" parser.banner = "Usage: user register login email phone [-P profile] [opt]"
Baguette::Log.info "Register a user (requires activation)." Baguette::Log.info "Register a user (requires activation)."
Context.command = "user-registration" Context.command = "user-registration"
# These options shouldn't be used here,
# email and phone parameters are mandatory.
# opt_email.call parser
# opt_phone.call parser
opt_profile.call parser opt_profile.call parser
opt_help.call parser opt_help.call parser
# login email # login email phone
unrecognized_args_to_context_args.call parser, 2 unrecognized_args_to_context_args.call parser, 3
end end
end end
@ -180,7 +190,7 @@ permission list: none read edit admin
END END
Baguette::Log.info "Set permissions." Baguette::Log.info "Set permissions."
Context.command = "permission-set" Context.command = "permission-set"
opt_authd_login.call parser opt_authd_admin.call parser
opt_help.call parser opt_help.call parser
# userid application resource permission # userid application resource permission
unrecognized_args_to_context_args.call parser, 4 unrecognized_args_to_context_args.call parser, 4
@ -195,7 +205,7 @@ permission list: none read edit admin
END END
Baguette::Log.info "Check permissions." Baguette::Log.info "Check permissions."
Context.command = "permission-check" Context.command = "permission-check"
opt_authd_login.call parser opt_authd_admin.call parser
opt_help.call parser opt_help.call parser
# userid application resource # userid application resource
unrecognized_args_to_context_args.call parser, 3 unrecognized_args_to_context_args.call parser, 3