Compare commits

...

14 Commits

21 changed files with 326 additions and 121 deletions

View File

@ -8,6 +8,11 @@ A combinaison of both is fine as long as the logic is comprehensively documented
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.
### New features
- On login: inform the user he doesn't have an email address.
This happens when the user was migrated.
### Structures, not classes
Maybe in some cases, it could be great to use structures instead of classes.

19
bin/migration-filter.awk Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/gawk -f
BEGIN {
OFS="\t"
should_print = 0
}
$1 ~ /^[-_ %ùÙêÊçÇéÉàÀ+a-zA-Z0-9'@.,;&]+$/ {
should_print = 1
}
should_print == 0 {
print "INVALID:", $1, $2
}
should_print == 1 {
print $1 "\t" $2
should_print = 0
}

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"

View File

@ -1,7 +1,6 @@
all: build-server
Q ?= @
SHOULD_UPDATE = ./bin/should-update
OPTS ?= --progress
NAME ?= John
@ -15,6 +14,8 @@ else
LOGIN_OPT = -l $(LOGIN)
endif
SOURCE_FILES = $(wildcard src/*.cr src/*/*.cr src/*/*/*.cr)
##################
### SETUP COMMANDS
##################
@ -36,39 +37,34 @@ add-first-user:
### 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)
add-user:; $(Q)./bin/authc user add $(NAME) $(EMAIL) $(LOGIN_OPT)
register:; $(Q)./bin/authc user register $(NAME) $(EMAIL)
validate:; $(Q)./bin/authc user validate $(NAME) $(ACTIVATION_KEY)
get-user:; $(Q)./bin/authc user get $(NAME) $(LOGIN_OPT)
get-user:
./bin/authc user get $(NAME) $(LOGIN_OPT)
USER_DB ?= /tmp/migration-authd-user-db.txt
$(USER_DB): ; ./bin/migration-filter.awk < /tmp/usrdb | grep -a -v "^INVALID" | sort | uniq > $(USER_DB)
migration-file: $(USER_DB)
migrate-user:; ./bin/authc user migrate $(NAME) $(PASSWORD_HASH) $(LOGIN_OPT)
migrate-all-users:; ./bin/authc migration-script $(USER_DB) $(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)
permission-check:; ./bin/authc permission check $(UID) $(SERVICE) $(RESOURCE) $(LOGIN_OPT)
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)
bin/authd: $(SOURCE_FILES); $(Q)shards build authd $(OPTS)
bin/authc: $(SOURCE_FILES); $(Q)shards build authc $(OPTS)
build-server: bin/authd
build-client: bin/authc
build: build-server build-client
doc:
@ -81,18 +77,12 @@ 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:]]+#'
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-no-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-no-comments:; make -s print-response-messages | grep -vE '^[[:blank:]]+#'
wipe-db:
rm -r $(DATA_DIRECTORY)

View File

@ -39,5 +39,8 @@ require "./authd/exceptions"
# Requests and responses.
require "./network"
# Run child processes.
require "./process"
# Functions to request the authd server.
require "./authd/client.cr"

View File

@ -70,6 +70,17 @@ module AuthD
], read
end
# Migration of a user from old code base (dnsmanager v1).
def migrate_user(login : String, password_hash_brkn : String)
send_now Request::MigrateUser.new login, password_hash_brkn
parse_message [
Response::UserAdded,
Response::ErrorMustBeAuthenticated,
Response::ErrorAlreadyUsedLogin,
Response::ErrorMailRequired
], read
end
def bootstrap(login : String,
password : String,
email : String,

View File

@ -4,6 +4,8 @@ require "uuid"
class AuthD::User
include JSON::Serializable
def_clone
enum PermissionLevel
None
Read
@ -18,10 +20,16 @@ class AuthD::User
class Contact
include JSON::Serializable
def_clone
# the activation key is removed once the user is validated
property activation_key : String? = nil
property email : String?
# Not yet validated email address: useful to keep a the previous validated email address
# until the new address is validated.
property pending_email : String? = nil
def initialize(@email = nil)
end
@ -38,6 +46,7 @@ class AuthD::User
# Private.
property contact : Contact
property password_hash_brkn : String? = nil # Old, broken algorithm.
property password_hash : String
property password_renew_key : String?
# service => resource => permission level

View File

@ -79,6 +79,17 @@ parser = OptionParser.new do |parser|
unrecognized_args_to_context_args.call parser, 2
end
parser.on "migration-script", "Adding a batch of users from old code base." do
parser.banner = "usage: migration-script user-db.txt"
Baguette::Log.info "Adding a batch of users."
Context.command = "migration-script"
opt_authd_login.call parser
opt_profile.call parser
opt_help.call parser
# user-db.txt
unrecognized_args_to_context_args.call parser, 1
end
parser.on "user", "Operations on users." do
parser.banner = "Usage: user [add | mod | delete | validate | search | get | recover | register ]"
@ -93,6 +104,17 @@ parser = OptionParser.new do |parser|
unrecognized_args_to_context_args.call parser, 2
end
parser.on "migrate", "Adding a user from old code base." do
parser.banner = "usage: user add login password-hash-brkn"
Baguette::Log.info "Adding a user to the DB."
Context.command = "user-migrate"
opt_authd_login.call parser
opt_profile.call parser
opt_help.call parser
# login password-hash-brkn
unrecognized_args_to_context_args.call parser, 2
end
parser.on "mod", "Modify a user account." do
parser.banner = "Usage: user mod userid [-e email|-P profile] [opt]"
Baguette::Log.info "Modify a user account."

View File

@ -62,6 +62,8 @@ class Actions
# Require admin privileges.
@the_call["user-add"] = ->user_add
@the_call["user-migrate"] = ->user_migrate
@the_call["migration-script"] = ->migration_script
@the_call["user-mod"] = ->user_mod
@the_call["permission-set"] = ->permission_set
@ -87,6 +89,52 @@ class Actions
puts "error: #{e.message}"
end
# Migrate a user from old code base (dnsmanager v1).
def user_migrate
args = Context.args.not_nil!
login, password_hash_brkn = args[0..1]
profile = Context.user_profile
pp! authd.migrate_user login, password_hash_brkn
rescue e : AuthD::Exception
puts "error: #{e.message}"
end
# Migrate a batch of users from dnsmanager v1.
#
# Usage: authc migration-script user-db.txt
#
# user-db.txt should be formated as "login <TAB> old-hash".
def migration_script
args = Context.args.not_nil!
filename = args[0]
profile = Context.user_profile
File.each_line(filename) do |line|
login, password_hash_brkn = line.split("\t")
STDOUT.write ((" " * 150) + "\r").to_slice
STDOUT.write "adding user '#{login}'\r".to_slice
response = authd.migrate_user login, password_hash_brkn
case response
when AuthD::Response::UserAdded
pp! response.user
when AuthD::Response::ErrorMustBeAuthenticated
Baguette::Log.error "ErrorMustBeAuthenticated"
exit 1
when AuthD::Response::ErrorAlreadyUsedLogin
#Baguette::Log.error "ErrorAlreadyUsedLogin"
when AuthD::Response::ErrorMailRequired
Baguette::Log.error "ErrorMailRequired"
else
Baguette::Log.error "unknown error"
end
end
rescue e : AuthD::Exception
puts "error: #{e.message}"
end
def user_registration
args = Context.args.not_nil!
login, email = args[0..1]
@ -143,7 +191,6 @@ class Actions
puts "error: #{e.message}"
end
# TODO
def user_mod
args = Context.args.not_nil!

17
src/configuration.cr Normal file
View File

@ -0,0 +1,17 @@
require "baguette-crystal-base"
class Baguette::Configuration
class Auth < IPC
property service_name : String = "auth"
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 = "/usr/local/bin/mailer"
property read_only_profile_keys : Array(String) = Array(String).new
property print_password_recovery_parameters : Bool = false
end
end

24
src/process.cr Normal file
View File

@ -0,0 +1,24 @@
def run_process(cmd : String, params : Array(String), env : Hash(String, String))
unless Process.run(cmd, params, env,
true # clear environment
# input: Process::Redirect::Inherit,
# output: Process::Redirect::Inherit,
# error: Process::Redirect::Inherit
).success?
raise "cannot run #{cmd} #{params.join(" ")}"
end
end
# Send a token to recovery the user's password.
def send_recovery_token(authd : AuthD::Service, login : String, email : String, token : String)
run_process(authd.configuration.mailer_exe,
[ "send", authd.configuration.recovery_template, email ],
{ "HOME" => "/", "LOGIN" => login, "TOKEN" => token })
end
# Send a token to validate the user's email address.
def send_activation_token(authd : AuthD::Service, login : String, email : String, token : String)
run_process(authd.configuration.mailer_exe,
[ "send", authd.configuration.activation_template, email ],
{ "HOME" => "/", "LOGIN" => login, "TOKEN" => token })
end

View File

@ -11,7 +11,8 @@ class AuthD::Request
# 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
Response::Login.new (token.to_s authd.configuration.secret_key), user.uid,
user.contact.email, user.contact.pending_email
end
IPC::JSON.message Login, 0 do
@ -34,10 +35,29 @@ class AuthD::Request
# 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,
# In case the user hasn't validated his email address (no email address but a token is present),
# authentication shouldn't be possible.
if user.contact.activation_key
return Response::ErrorInvalidCredentials.new
if user.contact.email.nil? && user.contact.activation_key
return Response::ErrorEmailAddressNotValidated.new
end
# MIGRATION
# The migration involves old (broken) hash algorithm.
# On first connection, the user is authenticated with the old algorithm then a new hash is generated.
if brkn_hash = user.password_hash_brkn
# Authenticates the user with its old password hash algo.
if brkn_hash != authd.obsolete_hash_password @password
Baguette::Log.error "cannot authenticate the user with his old password hash"
return Response::ErrorInvalidCredentials.new
end
# FYI: there is no need to clone the user since there are no indexes on passwords.
user.password_hash = authd.hash_password @password # Adding new password hash.
user.password_hash_brkn = nil # Removing old password hash.
Baguette::Log.info "updating password hash for #{user.login} to newer algorithm"
authd.users_per_login.update user
return AuthD::Request.perform_login authd, fd, user.not_nil!
end
pwhash = Sodium::Password::Hash.new

46
src/requests/migration.cr Normal file
View File

@ -0,0 +1,46 @@
class AuthD::Request
# Migration involves users with a broken password hash algorithm.
IPC::JSON.message MigrateUser, 16 do
property login : String
property password_hash_brkn : String # Old, broken algorithm. Will be changed on first authentication.
property admin : Bool = false
property email : String? = nil
property profile : Hash(String, JSON::Any)? = nil
def initialize(@login, @password_hash_brkn, @admin = false, @email = nil, @profile = 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?
logged_user.assert_permission("authd", "*", User::PermissionLevel::Admin)
if authd.users_per_login.get? @login
return Response::ErrorAlreadyUsedLogin.new
end
# No mail verification since there were no mail stored in dnsmanager v1.
uid = authd.new_uid
user = User.new uid, @login, "" # Current password is voluntarily not set.
user.password_hash_brkn = @password_hash_brkn
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 << MigrateUser
end

View File

@ -12,6 +12,7 @@ class AuthD::Request
logged_user = authd.get_logged_user_full? fd
return Response::ErrorMustBeAuthenticated.new if logged_user.nil?
# The user will be modified, we should get a COPY of the user.
user = if u = @user
logged_user.assert_permission("authd", "*", User::PermissionLevel::Edit)
authd.user? u
@ -20,23 +21,49 @@ class AuthD::Request
end
return Response::ErrorUserNotFound.new if user.nil?
cloned_user : AuthD::User = user.clone
# Only an admin can uprank or downrank someone.
if admin = @admin
logged_user.assert_permission("authd", "*", User::PermissionLevel::Admin)
user.admin = admin
cloned_user.admin = admin
end
@password.try do |s|
user.password_hash = authd.hash_password s
cloned_user.password_hash = authd.hash_password s
end
@email.try do |email|
user.contact.email = email
# In case of a new email address:
# 1. the new address is stored as "pending_email"
# 2. the new address has to be validated before being used as primary email address
if email = @email
# Verify the email address isn't already in the database.
if authd.users_per_email.get? Base64.encode(email).chomp
return Response::ErrorEmailAddressAlreadyUsed.new
end
cloned_user.contact.pending_email = email
cloned_user.contact.new_activation_key
begin
u_login = cloned_user.login
u_email = cloned_user.contact.pending_email.not_nil!
u_activation_token = cloned_user.contact.activation_key.not_nil!
# Once the user is created and stored, we try to contact him.
send_activation_token authd, u_login, u_email, u_activation_token
rescue e
Baguette::Log.error "mailer: #{e}"
return Response::ErrorCannotContactUser.new
end
end
authd.users_per_uid.update user.uid.to_s, user
begin
authd.users_per_uid.update cloned_user.uid.to_s, cloned_user
rescue e
return Response::Error.new "could not update the user (email may already be used)"
end
Response::UserEdited.new user.uid
Response::UserEdited.new cloned_user.uid
end
end
AuthD.requests << ModUser

View File

@ -35,26 +35,11 @@ class AuthD::Request
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
send_recovery_token authd, u_login, u_email, u_token
Response::PasswordRecoverySent.new
end

View File

@ -17,7 +17,7 @@ class AuthD::Request
return Response::ErrorAlreadyUsedLogin.new
end
acceptable_login_regex = "[a-zA-Z][-_ a-zA-Z0-9']+"
acceptable_login_regex = "[-_ %ùÙêÊçÇéÉàÀ+a-zA-Z0-9'@.,;&]+"
pattern = Regex.new acceptable_login_regex, Regex::Options::IGNORE_CASE
return Response::ErrorInvalidLoginFormat.new unless pattern =~ @login
@ -25,24 +25,27 @@ class AuthD::Request
return Response::ErrorMailRequired.new
end
if ! @email.nil?
if m = @email
# Test on the email address format.
grok = Grok.new [ "%{EMAILADDRESS:email}" ]
result = grok.parse @email.not_nil!
result = grok.parse m
email = result["email"]?
return Response::ErrorInvalidEmailFormat.new if email.nil?
# Verify the email address isn't already in the database.
return Response::ErrorEmailAddressAlreadyUsed.new if authd.users_per_email.get? Base64.encode(m).chomp
end
# In this case we should not accept its registration.
return Response::ErrorPasswordTooShort.new if @password.size < 20
return Response::ErrorPasswordTooShort.new if @password.size < 15
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.pending_email = @email unless @email.nil?
user.contact.new_activation_key
@profile.try do |profile|
@ -52,26 +55,12 @@ class AuthD::Request
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!
u_login = user.login
u_email = user.contact.pending_email.not_nil!
u_activation_token = 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
send_activation_token authd, u_login, u_email, u_activation_token
rescue e
Baguette::Log.error "mailer: #{e}"
return Response::ErrorCannotContactUser.new

View File

@ -21,14 +21,17 @@ class AuthD::Request
result = if regex = @regex
pattern = Regex.new regex, Regex::Options::IGNORE_CASE
users.each do |u|
puts "trying to match user #{u.login}"
if pattern =~ u.login || u.profile.try do |profile|
full_name = profile["full_name"]?
puts "login didn't work, trying to match its full name: #{full_name}"
if full_name.nil?
false
else
pattern =~ full_name.as_s
end
end || u.contact.email.try do |email|
puts "full name didn't work, trying to match its email: #{email}"
pattern =~ email
end
Baguette::Log.debug "#{u.login} matches #{pattern}"

View File

@ -17,13 +17,17 @@ class AuthD::Request
end
# Remove the user contact activation key: the email is validated.
if user.contact.activation_key == @activation_key
user.contact.activation_key = nil
else
if user.contact.activation_key != @activation_key
return Response::ErrorInvalidActivationKey.new
end
authd.users_per_uid.update user.uid.to_s, user
cloned_user = user.clone
cloned_user.contact.activation_key = nil
cloned_user.contact.email = cloned_user.contact.pending_email
cloned_user.contact.pending_email = nil
authd.users_per_uid.update cloned_user
Response::UserValidated.new user.to_public
end

View File

@ -102,4 +102,16 @@ class AuthD::Response
end
end
AuthD.responses << ErrorPasswordTooLong
IPC::JSON.message ErrorEmailAddressNotValidated, 36 do
def initialize()
end
end
AuthD.responses << ErrorEmailAddressNotValidated
IPC::JSON.message ErrorEmailAddressAlreadyUsed, 37 do
def initialize()
end
end
AuthD.responses << ErrorEmailAddressAlreadyUsed
end

View File

@ -2,7 +2,9 @@ class AuthD::Response
IPC::JSON.message Login, 1 do
property uid : UInt32
property token : String
def initialize(@token, @uid)
property current_email : String? = nil
property pending_email : String? = nil
def initialize(@token, @uid, @current_email, @pending_email)
end
end
AuthD.responses << Login

View File

@ -3,20 +3,7 @@ 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
require "./configuration"
# Provides a JWT-based authentication scheme for service-specific users.
class AuthD::Service < IPC
@ -57,7 +44,7 @@ class AuthD::Service < IPC
end
self.timer @configuration.ipc_timer
self.service_init "auth"
self.service_init @configuration.service_name
end
def obsolete_hash_password(password : String) : String
@ -173,7 +160,7 @@ class AuthD::Service < IPC
end
def run
Baguette::Log.title "Starting authd"
Baguette::Log.title "Starting #{@configuration.service_name}"
Baguette::Log.info "(mailer) Email activation template: #{@configuration.activation_template}"
Baguette::Log.info "(mailer) Email recovery template: #{@configuration.recovery_template}"