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 is a token-based authentication micro-service.
> TODO: explain basic concepts behind `authd`.
## Build
`authd` is written in Crystal.
Youll need the following tools:
`authd` is written in Crystal and uses `build.zsh` as Makefile generator, as
well as shards to fetch dependencies.
Youll need the following tools to build authd:
- crystal
- shards
- build.zsh
- make
To build authd, run the following commands:
```
shards install
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
```
$ 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
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.
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
> 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
@ -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.
> TODO: document messages.
### 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.
# Authorization rules
```crystal
require "authd"
Logged users can:
- retrieve public data of any user **individually**
- change their own data: password, email address, profile entries (except the read-only ones)
- delete their account
- check their own permissions
authd = AuthD::Client.new
authd.key = File.read("./some-file").chomp
Admins with 'Read' permission on the '*' resource can:
- list users
- check permissions of other users
pp! r = authd.get_token?("login", "password")
Admins with 'Edit' permission on the '*' resource can:
- change data of another user
pp! r = authd.add_user("login", "password")
Admins with 'Admin' permission on the '*' resource (or the 'admin' boolean) can:
- change read-only profile entries
- change permissions
- delete a user
- uprank and downrank admins
pp! u = authd.get_user?("login", "password").not_nil!
```
## 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.
# 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
version: 0.1.0
description: |
JWT-based authentication daemon.
version: 0.2.0
authors:
- Philippe Pittoli <karchnu@karchnu.fr>
- Karchnu <karchnu@karchnu.fr>
- Luka Vandervelden <lukc@upyum.com>
description: |
JWT-based authentication daemon.
targets:
authd:
main: src/server.cr
main: src/main.cr
authc:
main: src/client.cr
main: utils/authc.cr
crystal: 1.7.1
crystal: 0.35.1
dependencies:
sodium:
branch: master
github: didactic-drunk/sodium.cr
grok:
github: spinscale/grok.cr
passwd:
git: https://git.baguette.netlib.re/Baguette/passwd.cr
branch: master
jwt:
github: crystal-community/jwt
branch: master
baguette-crystal-base:
git: https://git.baguette.netlib.re/Baguette/baguette-crystal-base
branch: master
dodb:
git: https://git.baguette.netlib.re/Baguette/dodb.cr
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
grok:
github: spinscale/grok.cr
passwd:
git: https://git.baguette.netlib.re/Baguette/passwd.cr
branch: master
ipc:
git: https://git.baguette.netlib.re/Baguette/ipc.cr
branch: master
jwt:
github: crystal-community/jwt
branch: master
baguette-crystal-base:
git: https://git.baguette.netlib.re/Baguette/baguette-crystal-base
branch: master
dodb:
git: https://git.baguette.netlib.re/Baguette/dodb.cr
branch: master
license: ISC
license: EUPL

View File

@ -1,43 +1,659 @@
require "uuid"
require "option_parser"
require "openssl"
require "colorize"
require "jwt"
require "grok"
require "json"
require "jwt"
require "dodb"
require "ipc"
require "baguette-crystal-base"
require "./user.cr"
# In any message, a user can be referred by its UInt32 uid or its login.
alias UserID = UInt32 | String
class AuthD::Exception < Exception
end
# Allows get configuration from a provided file.
# See Baguette::Configuration::Base.get
class Baguette::Configuration
class Auth < IPC
include YAML::Serializable
class AuthD::MalformedRequest < Exception
getter ipc_type : Int32
getter payload : String
property login : String? = nil
property pass : String? = nil
property secret_key : String = "nico-nico-nii" # Default authd key, as per the specs. :eyes:
property secret_key_file : String? = nil
def initialize(@ipc_type, @payload)
@message = "malformed payload"
end
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
# Token and user classes.
require "./authd/token.cr"
require "./authd/user.cr"
class AuthD::Request
include JSON::Serializable
# Requests and responses.
require "./authd/exceptions"
property id : JSON::Any?
# Requests and responses.
require "./network"
annotation MessageType
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
property login : String
property uid : UInt32
property uid : Int32
def initialize(@login, @uid)
end
@ -23,7 +23,7 @@ class AuthD::Token
def self.from_s(key, str)
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

View File

@ -1,6 +1,9 @@
require "json"
require "uuid"
require "./token.cr"
class AuthD::User
include JSON::Serializable
@ -19,21 +22,18 @@ class AuthD::User
include JSON::Serializable
# the activation key is removed once the user is validated
property activation_key : String? = nil
property activation_key : String?
property email : String?
property phone : String?
def initialize(@email = nil)
end
def new_activation_key
def initialize(@email = nil, @phone = nil)
@activation_key = UUID.random.to_s
end
end
# Public.
property login : String
property uid : UInt32
property admin : Bool = false
property uid : Int32
property profile : Hash(String, JSON::Any)?
# Private.
@ -50,7 +50,7 @@ class AuthD::User
Token.new @login, @uid
end
def initialize(@uid, @login, @password_hash, @admin = false)
def initialize(@uid, @login, @password_hash)
@contact = Contact.new
@permissions = Hash(String, Hash(String, PermissionLevel)).new
@configuration = Hash(String, Hash(String, JSON::Any)).new
@ -60,35 +60,17 @@ class AuthD::User
include JSON::Serializable
property login : String
property uid : UInt32
property admin : Bool = false
property uid : Int32
property profile : Hash(String, JSON::Any)?
property date_registration : Time?
def initialize(@uid, @login, @admin, @profile, @date_registration)
def initialize(@uid, @login, @profile, @date_registration)
end
end
def to_public : Public
Public.new @uid, @login, @admin, @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
Public.new @uid, @login, @profile, @date_registration
end
end

View File

@ -1,12 +1,23 @@
require "option_parser"
require "ipc"
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_property simulation = false # do not perform the action
class_property authd_login : String? = nil
class_property authd_pass : String? = nil
class_property authd_login = "undef" # undef authd user
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.
# class_property print_title = true
@ -17,12 +28,14 @@ class Context
class_property command = "not-implemented"
class_property user_profile : Hash(String,JSON::Any)?
class_property phone : String?
class_property email : String?
# Will be parsed later, with a specific parser.
class_property args : Array(String)? = nil
end
# require "./parse-me"
require "./better-parser"
class Actions
@ -51,18 +64,14 @@ class Actions
property authd : AuthD::Client
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-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-check"] = ->permission_check
@ -74,76 +83,33 @@ class Actions
#
def user_add
puts "User add!!!"
args = Context.args.not_nil!
login, email = args[0..1]
login, email, phone = args[0..2]
profile = Context.user_profile
password = Actions.ask_password
exit 1 unless password
# By default: not admin.
pp! authd.add_user login, password.not_nil!, false, email, profile: profile
pp! authd.add_user login, password.not_nil!, email, phone, profile: profile
rescue e : AuthD::Exception
puts "error: #{e.message}"
end
def user_registration
args = Context.args.not_nil!
login, email = args[0..1]
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]
login, email, phone = args[0..2]
profile = Context.user_profile
password = Actions.ask_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
puts "error: #{e.message}"
end
# TODO
def user_mod
args = Context.args.not_nil!
@ -162,19 +128,25 @@ class Actions
end
email = Context.email
phone = Context.phone
Baguette::Log.error "This function shouldn't be used for now."
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
end
def user_deletion
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
end
@ -196,37 +168,27 @@ class Actions
end
def user_recovery
args = Context.args.not_nil!
login = args[0]
pp! authd.ask_password_recovery login
login, email = args[0..1]
pp! authd.ask_password_recovery login, email
end
def permission_check
args = Context.args.not_nil!
user, application, resource = args[0..2]
res = @authd.check_permission user.to_u32, application, resource
case res
when Response::PermissionCheck
s = res.service
r = res.resource
u = res.user
p = res.permission
Baguette::Log.info "app #{s} resource #{r} user #{u}: #{p}"
end
# pp! user, application, resource
res = @authd.check_permission user.to_i, application, resource
puts res
end
def permission_set
args = Context.args.not_nil!
user, application, resource, permission = args[0..3]
# pp! user, application, resource, permission
perm = AuthD::User::PermissionLevel.parse(permission)
res = @authd.set_permission user.to_u32, application, resource, perm
case 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
res = @authd.set_permission user.to_i, application, resource, perm
puts res
end
end
@ -234,25 +196,18 @@ def main
# Authd connection.
authd = AuthD::Client.new
authd.key = Context.shared_key if Context.shared_key != "undef"
if login = Context.authd_login
pass = if p = Context.authd_pass
p
else
password = Actions.ask_password
raise "cannot get a password" unless password
password
end
response = authd.login? login, pass
case response
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
# Authd token.
# FIXME: not sure about getting the token, it seems not used elsewhere.
# If login == pass == "undef": do not even try.
#unless Context.authd_login == Context.authd_pass && Context.authd_login == "undef"
# login = Context.authd_login
# pass = Context.authd_pass
# token = authd.get_token? login, pass
# raise "cannot get a token" if token.nil?
# # authd.login token
#end
actions = Actions.new authd
@ -261,8 +216,6 @@ def main
actions.the_call[Context.command].call
rescue e
Baguette::Log.info "The command is not recognized (or implemented)."
Baguette::Log.info "Exception: #{e}."
pp! e
end
# authd disconnection
@ -276,3 +229,4 @@ end
# tool [options] command [options-for-command]
main

View File

@ -1,5 +1,12 @@
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
opt_authd_login = -> (parser : OptionParser) {
parser.on "-l LOGIN", "--login LOGIN", "Authd user login." do |login|
@ -26,6 +33,13 @@ opt_profile = -> (parser : OptionParser) {
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) {
parser.on "-e email", "--email address", "Email address." do |email|
Context.email = email
@ -35,15 +49,13 @@ opt_email = -> (parser : OptionParser) {
# Unrecognized parameters are used to create commands with multiple arguments.
# Example: user add login email
# Here, login and email are unrecognized arguments.
# Example: user add _login email phone_
# Here, login, email and phone are unrecognized arguments.
# Still, the "user add" command expect them.
unrecognized_args_to_context_args = -> (parser : OptionParser, n_expected_args : Int32) {
# With the right args, these will be interpreted as serialized data.
parser.unknown_args do |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}"
exit 1
end
@ -69,36 +81,29 @@ parser = OptionParser.new do |parser|
exit 0
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.banner = "Usage: user [add | mod | delete | validate | search | get | recover | register ]"
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."
Context.command = "user-add"
opt_authd_login.call parser
opt_authd_admin.call parser
opt_profile.call parser
opt_email.call parser
opt_phone.call parser
opt_help.call parser
# login email
unrecognized_args_to_context_args.call parser, 2
# login email phone
unrecognized_args_to_context_args.call parser, 3
end
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."
Context.command = "user-mod"
opt_authd_login.call parser
opt_authd_admin.call parser
opt_email.call parser
opt_phone.call parser
opt_profile.call parser
opt_help.call parser
# userid
@ -111,6 +116,7 @@ parser = OptionParser.new do |parser|
Context.command = "user-delete"
# You can either be the owner of the account, or an admin.
opt_authd_login.call parser
opt_authd_admin.call parser
opt_help.call parser
# userid
unrecognized_args_to_context_args.call parser, 1
@ -119,7 +125,7 @@ parser = OptionParser.new do |parser|
parser.on "validate", "Validate user." do
parser.banner = "Usage: user validate login activation-key [opt]"
Baguette::Log.info "Validate user."
Context.command = "user-validation"
Context.command = "user-validate"
# No need to be authenticated.
opt_help.call parser
# login activation-key
@ -130,7 +136,7 @@ parser = OptionParser.new do |parser|
parser.banner = "Usage: user get login [opt]"
Baguette::Log.info "Get user info."
Context.command = "user-get"
opt_authd_login.call parser
# No need to be authenticated.
opt_help.call parser
# login
unrecognized_args_to_context_args.call parser, 1
@ -140,32 +146,36 @@ parser = OptionParser.new do |parser|
parser.banner = "Usage: user recover login [opt]"
Baguette::Log.info "Search user."
Context.command = "user-search"
opt_authd_login.call parser
# No need to be authenticated.
opt_help.call parser
# login
unrecognized_args_to_context_args.call parser, 1
end
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."
Context.command = "user-recovery"
# No need to be authenticated.
opt_help.call parser
# login email
unrecognized_args_to_context_args.call parser, 1
unrecognized_args_to_context_args.call parser, 2
end
# Do not require to be admin.
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)."
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_help.call parser
# login email
unrecognized_args_to_context_args.call parser, 2
# login email phone
unrecognized_args_to_context_args.call parser, 3
end
end
@ -180,7 +190,7 @@ permission list: none read edit admin
END
Baguette::Log.info "Set permissions."
Context.command = "permission-set"
opt_authd_login.call parser
opt_authd_admin.call parser
opt_help.call parser
# userid application resource permission
unrecognized_args_to_context_args.call parser, 4
@ -195,7 +205,7 @@ permission list: none read edit admin
END
Baguette::Log.info "Check permissions."
Context.command = "permission-check"
opt_authd_login.call parser
opt_authd_admin.call parser
opt_help.call parser
# userid application resource
unrecognized_args_to_context_args.call parser, 3