diff --git a/zig-impl/crystal/some-crystal-app/authd/authd.cr b/zig-impl/crystal/some-crystal-app/authd/authd.cr new file mode 100644 index 0000000..c7e4615 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/authd.cr @@ -0,0 +1,31 @@ +require "json" +require "jwt" +require "../src/main.cr" + +require "baguette-crystal-base" +require "./user.cr" + +# Allows get configuration from a provided file. +# See Baguette::Configuration::Base.get +class Baguette::Configuration + class Auth < IPC + include YAML::Serializable + + property login : String? = nil + property pass : String? = nil + property shared_key : String = "nico-nico-nii" # Default authd key, as per the specs. :eyes: + property shared_key_file : String? = nil + + def initialize + end + end +end + +# Requests and responses. +require "./exceptions" + +# Requests and responses. +require "./network" + +# Functions to request the authd server. +require "./libclient.cr" diff --git a/zig-impl/crystal/some-crystal-app/authd/exceptions.cr b/zig-impl/crystal/some-crystal-app/authd/exceptions.cr new file mode 100644 index 0000000..f2f0a08 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/exceptions.cr @@ -0,0 +1,13 @@ +module AuthD + class Exception < ::Exception + end + + class UserNotFound < ::Exception + end + + class AuthenticationInfoLacking < ::Exception + end + + class AdminAuthorizationException < ::Exception + end +end diff --git a/zig-impl/crystal/some-crystal-app/authd/libclient.cr b/zig-impl/crystal/some-crystal-app/authd/libclient.cr new file mode 100644 index 0000000..072e0ce --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/libclient.cr @@ -0,0 +1,242 @@ +require "ipc/json" + +module AuthD + class Client < IPC + property key : String + + def initialize + super + @key = "" + end + + def get_token?(login : String, password : String) : String? + send_now Request::GetToken.new login, password + + response = AuthD.responses.parse_ipc_json read + + if response.is_a?(Response::Token) + response.token + else + nil + end + end + + def get_user?(login : String, password : String) : AuthD::User::Public? + send_now Request::GetUserByCredentials.new login, password + + response = AuthD.responses.parse_ipc_json 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_now Request::GetUser.new uid_or_login + + response = AuthD.responses.parse_ipc_json read + + if response.is_a? Response::User + response.user + else + nil + end + end + + def send_now(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_now Request::AddUser.new @key, login, password, email, phone, profile + + response = AuthD.responses.parse_ipc_json read + + case response + when Response::UserAdded + response.user + when Response::Error + raise Exception.new response.reason + else + # Should not happen in serialized connections, but… + # it’ll 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_now Request::ValidateUser.new login, activation_key + + response = AuthD.responses.parse_ipc_json read + + case response + when Response::UserValidated + response.user + when Response::Error + raise Exception.new response.reason + else + # Should not happen in serialized connections, but… + # it’ll happen if you run several requests at once. + Exception.new + end + end + + def ask_password_recovery(uid_or_login : String | Int32, email : String) + send_now Request::AskPasswordRecovery.new uid_or_login, email + response = AuthD.responses.parse_ipc_json 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_now Request::PasswordRecovery.new uid_or_login, renew_key, new_pass + response = AuthD.responses.parse_ipc_json 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_now Request::Register.new login, password, email, phone, profile + response = AuthD.responses.parse_ipc_json 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_now request + + response = AuthD.responses.parse_ipc_json 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_now request + + response = AuthD.responses.parse_ipc_json 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_now request + + response = AuthD.responses.parse_ipc_json 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_now Request::SearchUser.new user_login + response = AuthD.responses.parse_ipc_json 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_now Request::EditProfileContent.new key, user, new_values + response = AuthD.responses.parse_ipc_json 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_now Request::Delete.new user, key + delete_ + end + def delete(user : Int32 | String, login : String, pass : String) + send_now Request::Delete.new user, login, pass + delete_ + end + def delete_ + response = AuthD.responses.parse_ipc_json read + case response + when Response::Error + raise Exception.new response.reason + end + response + end + end +end diff --git a/zig-impl/crystal/some-crystal-app/authd/main.cr b/zig-impl/crystal/some-crystal-app/authd/main.cr new file mode 100644 index 0000000..d4e8bb4 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/main.cr @@ -0,0 +1,238 @@ +require "uuid" +require "option_parser" +require "openssl" +require "colorize" + +require "jwt" +require "../src/main.cr" +require "dodb" + +require "baguette-crystal-base" +require "grok" + +require "./authd.cr" + +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_url : String? = nil + property field_subject : String? = nil + property field_from : String? = nil + 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) + + # #{@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 + + @last_uid_file = "#{@configuration.storage}/last_used_uid" + + if @configuration.recreate_indexes + @users.reindex_everything! + end + + self.timer @configuration.ipc_timer + self.service_init "auth" + end + + def hash_password(password : String) : String + digest = OpenSSL::Digest.new "sha256" + digest << password + digest.hexfinal + 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(event : IPC::Event) + request_start = Time.utc + + request = AuthD.requests.parse_ipc_json event.message.not_nil! + + if request.nil? + raise "unknown request type" + end + + request_name = request.class.name.sub /^AuthD::Request::/, "" + Baguette::Log.debug "<< #{request_name}" + + response = begin + request.handle self + rescue e : UserNotFound + Baguette::Log.error "#{request_name} user not found" + AuthD::Response::Error.new "authorization error" + rescue e : AuthenticationInfoLacking + Baguette::Log.error "#{request_name} lacking authentication info" + AuthD::Response::Error.new "authorization error" + rescue e : AdminAuthorizationException + Baguette::Log.error "#{request_name} admin authentication failed" + AuthD::Response::Error.new "authorization error" + rescue e + Baguette::Log.error "#{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 ">> #{response_name} (#{response.reason})" + else + Baguette::Log.debug ">> #{response_name} (Total duration: #{duration})" + end + end + + def get_user_from_token(token : String) + token_payload = Token.from_s(@configuration.shared_key, token) + + @users_per_uid.get? token_payload.uid.to_s + end + + def run + Baguette::Log.title "Starting authd" + + 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 + else + Baguette::Log.error "Not implemented behavior for event: #{event}" + end + end + + end +end + + +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.shared_key_file + configuration.shared_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.shared_key = File.read(file_name).chomp + end + + parser.on "-R", "--allow-registrations" do + configuration.registrations = true + end + + parser.on "-E", "--require-email" do + configuration.require_email = true + end + + parser.on "-t subject", "--subject title", "Subject of the email." do |s| + configuration.field_subject = s + end + + parser.on "-f from-email", "--from email", "'From:' field to use in activation email." do |f| + configuration.field_from = f + end + + parser.on "-u", "--activation-url url", "Activation URL." do |opt| + configuration.activation_url = 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 + diff --git a/zig-impl/crystal/some-crystal-app/authd/network.cr b/zig-impl/crystal/some-crystal-app/authd/network.cr new file mode 100644 index 0000000..0f18a07 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/network.cr @@ -0,0 +1,22 @@ +require "../src/main.cr" +require "../src/json" + +class IPC::JSON + def handle(service : AuthD::Service) + 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/*" diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/admin.cr b/zig-impl/crystal/some-crystal-app/authd/requests/admin.cr new file mode 100644 index 0000000..8ff4e54 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/admin.cr @@ -0,0 +1,100 @@ +class AuthD::Request + IPC::JSON.message AddUser, 1 do + # 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? = nil + property phone : String? = nil + property profile : Hash(String, JSON::Any)? = nil + + def initialize(@shared_key, @login, @password, @email, @phone, @profile) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + # No verification of the users' informations when an admin adds it. + # No mail address verification. + if @shared_key != authd.configuration.shared_key + return Response::Error.new "invalid authentication key" + end + + if authd.users_per_login.get? @login + return Response::Error.new "login already used" + end + + if authd.configuration.require_email && @email.nil? + return Response::Error.new "email required" + 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.contact.phone = @phone unless @phone.nil? + + @profile.try do |profile| + user.profile = profile + end + + # We consider adding the user as a registration + user.date_registration = Time.local + + authd.users << user + + Response::UserAdded.new user.to_public + end + end + AuthD.requests << AddUser + + + IPC::JSON.message ModUser, 5 do + property shared_key : String + + property user : Int32 | String + property password : String? = nil + property email : String? = nil + property phone : String? = nil + property avatar : String? = nil + + def initialize(@shared_key, @user) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + if @shared_key != authd.configuration.shared_key + return Response::Error.new "invalid authentication key" + end + + uid_or_login = @user + user = if uid_or_login.is_a? Int32 + authd.users_per_uid.get? uid_or_login.to_s + else + authd.users_per_login.get? uid_or_login + end + + unless user + return Response::Error.new "user not found" + end + + @password.try do |s| + user.password_hash = authd.hash_password s + end + + @email.try do |email| + user.contact.email = email + end + + @phone.try do |phone| + user.contact.phone = phone + end + + authd.users_per_uid.update user.uid.to_s, user + + Response::UserEdited.new user.uid + end + end + AuthD.requests << ModUser +end diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/contact.cr b/zig-impl/crystal/some-crystal-app/authd/requests/contact.cr new file mode 100644 index 0000000..5149ebc --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/contact.cr @@ -0,0 +1,46 @@ +class AuthD::Request + IPC::JSON.message EditContacts, 16 do + property token : String + + property email : String? = nil + property phone : String? = nil + + def initialize(@token) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + user = authd.get_user_from_token @token + + return Response::Error.new "invalid user" unless user + + if email = @email + # FIXME: This *should* require checking the new mail, with + # a new activation key and everything else. + user.contact.email = email + end + + authd.users_per_uid.update user + + Response::UserEdited.new user.uid + end + end + AuthD.requests << EditContacts + + IPC::JSON.message GetContacts, 18 do + property token : String + + def initialize(@token) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + user = authd.get_user_from_token @token + + return Response::Error.new "invalid user" unless user + + _c = user.contact + + Response::Contacts.new user.uid, _c.email, _c.phone + end + end + AuthD.requests << GetContacts +end diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/delete.cr b/zig-impl/crystal/some-crystal-app/authd/requests/delete.cr new file mode 100644 index 0000000..e2e30ad --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/delete.cr @@ -0,0 +1,69 @@ +class AuthD::Request + IPC::JSON.message Delete, 17 do + # Deletion can be triggered by either an admin or the user. + property shared_key : String? = nil + + property login : String? = nil + property password : String? = nil + + property user : String | Int32 + + def initialize(@user, @login, @password) + end + def initialize(@user, @shared_key) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + uid_or_login = @user + user_to_delete = if uid_or_login.is_a? Int32 + authd.users_per_uid.get? uid_or_login.to_s + else + authd.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 = @shared_key + return Response::Error.new "unauthorized (wrong shared key)" unless key == authd.configuration.shared_key + else + login = @login + pass = @password + if login.nil? || pass.nil? + return Response::Error.new "authentication failed (no shared key, no login)" + end + + # authenticate the user + begin + user = authd.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 != authd.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. + authd.users_per_login.delete user_to_delete.login + + # TODO: better response + Response::User.new user_to_delete.to_public + end + end + AuthD.requests << Delete + +end diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/list.cr b/zig-impl/crystal/some-crystal-app/authd/requests/list.cr new file mode 100644 index 0000000..edd05d1 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/list.cr @@ -0,0 +1,41 @@ +class AuthD::Request + IPC::JSON.message ListUsers, 8 do + property token : String? = nil + property key : String? = nil + + def initialize(@token, @key) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + # FIXME: Lines too long, repeatedly (>80c with 4c tabs). + @token.try do |token| + user = authd.get_user_from_token token + + return Response::Error.new "unauthorized (user not found from token)" unless user + + # Test if the user is a moderator. + if permissions = user.permissions["authd"]? + if rights = permissions["*"]? + if rights >= User::PermissionLevel::Read + else + raise AdminAuthorizationException.new "unauthorized (insufficient rights on '*')" + end + else + raise AdminAuthorizationException.new "unauthorized (no rights on '*')" + end + else + raise AdminAuthorizationException.new "unauthorized (user not in authd group)" + end + end + + @key.try do |key| + return Response::Error.new "unauthorized (wrong shared key)" unless key == authd.configuration.shared_key + end + + return Response::Error.new "unauthorized (no key nor token)" unless @key || @token + + Response::UsersList.new authd.users.to_h.map &.[1].to_public + end + end + AuthD.requests << ListUsers +end diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/password.cr b/zig-impl/crystal/some-crystal-app/authd/requests/password.cr new file mode 100644 index 0000000..646f469 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/password.cr @@ -0,0 +1,125 @@ +class AuthD::Request + IPC::JSON.message UpdatePassword, 7 do + property login : String + property old_password : String + property new_password : String + + def initialize(@login, @old_password, @new_password) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + user = authd.users_per_login.get? @login + + unless user + return Response::Error.new "invalid credentials" + end + + if authd.hash_password(@old_password) != user.password_hash + return Response::Error.new "invalid credentials" + end + + user.password_hash = authd.hash_password @new_password + + authd.users_per_uid.update user.uid.to_s, user + + Response::UserEdited.new user.uid + end + end + AuthD.requests << UpdatePassword + + IPC::JSON.message PasswordRecovery, 11 do + property user : Int32 | String + property password_renew_key : String + property new_password : String + + def initialize(@user, @password_renew_key, @new_password) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + uid_or_login = @user + user = if uid_or_login.is_a? Int32 + authd.users_per_uid.get? uid_or_login.to_s + else + authd.users_per_login.get? uid_or_login + end + + if user.nil? + return Response::Error.new "user not found" + end + + if user.password_renew_key == @password_renew_key + user.password_hash = authd.hash_password @new_password + else + return Response::Error.new "renew key not valid" + end + + user.password_renew_key = nil + + authd.users_per_uid.update user.uid.to_s, user + + Response::PasswordRecovered.new user.to_public + end + end + AuthD.requests << PasswordRecovery + + IPC::JSON.message AskPasswordRecovery, 12 do + property user : Int32 | String + property email : String + + def initialize(@user, @email) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + uid_or_login = @user + user = if uid_or_login.is_a? Int32 + authd.users_per_uid.get? uid_or_login.to_s + else + authd.users_per_login.get? uid_or_login + end + + if user.nil? + return Response::Error.new "no such user" + end + + if user.contact.email != @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 + + authd.users_per_uid.update user.uid.to_s, user + + unless (activation_url = authd.configuration.activation_url).nil? + + field_from = authd.configuration.field_from.not_nil! + activation_url = authd.configuration.activation_url.not_nil! + + # 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!, + field_from, + activation_url, + user.password_renew_key.not_nil! + end + + unless Process.run("password-recovery-mailer", [ + "-l", user.login, + "-e", user.contact.email.not_nil!, + "-t", "Password recovery email", + "-f", field_from, + "-u", 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 + end + end + AuthD.requests << AskPasswordRecovery +end diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/permissions.cr b/zig-impl/crystal/some-crystal-app/authd/requests/permissions.cr new file mode 100644 index 0000000..fdc2b6c --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/permissions.cr @@ -0,0 +1,113 @@ +class AuthD::Request + IPC::JSON.message CheckPermission, 9 do + property shared_key : String? = nil + property token : String? = nil + + property user : Int32 | String + property service : String + property resource : String + + def initialize(@shared_key, @user, @service, @resource) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + authorized = false + + if key = @shared_key + if key == authd.configuration.shared_key + authorized = true + else + return Response::Error.new "invalid key provided" + end + end + + if token = @token + user = authd.get_user_from_token token + + if user.nil? + return Response::Error.new "token does not match user" + end + + if user.login != @user && user.uid != @user + return Response::Error.new "token does not match user" + end + + authorized = true + end + + unless authorized + return Response::Error.new "unauthorized" + end + + user = case u = @user + when .is_a? Int32 + authd.users_per_uid.get? u.to_s + else + authd.users_per_login.get? u + end + + if user.nil? + return Response::Error.new "no such user" + end + + service = @service + service_permissions = user.permissions[service]? + + if service_permissions.nil? + return Response::PermissionCheck.new service, @resource, user.uid, User::PermissionLevel::None + end + + resource_permissions = service_permissions[@resource]? + + if resource_permissions.nil? + return Response::PermissionCheck.new service, @resource, user.uid, User::PermissionLevel::None + end + + return Response::PermissionCheck.new service, @resource, user.uid, resource_permissions + end + end + AuthD.requests << CheckPermission + + IPC::JSON.message SetPermission, 10 do + property shared_key : String + + property user : Int32 | String + property service : String + property resource : String + property permission : ::AuthD::User::PermissionLevel + + def initialize(@shared_key, @user, @service, @resource, @permission) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + unless @shared_key == authd.configuration.shared_key + return Response::Error.new "unauthorized" + end + + user = authd.users_per_uid.get? @user.to_s + + if user.nil? + return Response::Error.new "no such user" + end + + service = @service + 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 diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/profile.cr b/zig-impl/crystal/some-crystal-app/authd/requests/profile.cr new file mode 100644 index 0000000..daa2a16 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/profile.cr @@ -0,0 +1,93 @@ +class AuthD::Request + IPC::JSON.message EditProfile, 14 do + property token : String + property new_profile : Hash(String, JSON::Any) + + def initialize(@token, @new_profile) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + user = authd.get_user_from_token @token + + return Response::Error.new "invalid user" unless user + + new_profile = @new_profile + + profile = user.profile || Hash(String, JSON::Any).new + + authd.configuration.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 + + authd.users_per_uid.update user.uid.to_s, user + + Response::User.new user.to_public + end + end + AuthD.requests << EditProfile + + # Same as above, but doesn’t reset the whole profile, only resets elements + # for which keys are present in `new_profile`. + IPC::JSON.message EditProfileContent, 15 do + property token : String? = nil + + property shared_key : String? = nil + property user : Int32 | String | Nil + + property new_profile : Hash(String, JSON::Any) + + def initialize(@shared_key, @user, @new_profile) + end + def initialize(@token, @new_profile) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + user = if token = @token + u = authd.get_user_from_token token + raise UserNotFound.new unless u + u + elsif shared_key = @shared_key + raise AdminAuthorizationException.new if shared_key != authd.configuration.shared_key + + u = @user + raise UserNotFound.new unless u + + u = if u.is_a? Int32 + authd.users_per_uid.get? u.to_s + else + authd.users_per_login.get? u + end + raise UserNotFound.new unless u + + u + else + raise AuthenticationInfoLacking.new + end + + new_profile = user.profile || Hash(String, JSON::Any).new + + unless @shared_key + authd.configuration.read_only_profile_keys.each do |key| + if @new_profile.has_key? key + return Response::Error.new "tried to edit read only key" + end + end + end + + @new_profile.each do |key, value| + new_profile[key] = value + end + + user.profile = new_profile + + authd.users_per_uid.update user.uid.to_s, user + + Response::User.new user.to_public + end + end + AuthD.requests << EditProfileContent +end diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/register.cr b/zig-impl/crystal/some-crystal-app/authd/requests/register.cr new file mode 100644 index 0000000..8407174 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/register.cr @@ -0,0 +1,92 @@ +class AuthD::Request + IPC::JSON.message Register, 6 do + property login : String + property password : String + property email : String? = nil + property phone : String? = nil + property profile : Hash(String, JSON::Any)? = nil + + def initialize(@login, @password, @email, @phone, @profile) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + if ! authd.configuration.registrations + return Response::Error.new "registrations not allowed" + end + + if authd.users_per_login.get? @login + return Response::Error.new "login already used" + end + + if authd.configuration.require_email && @email.nil? + return Response::Error.new "email required" + end + + activation_url = authd.configuration.activation_url + if 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 ! @email.nil? + # Test on the email address format. + grok = Grok.new [ "%{EMAILADDRESS:email}" ] + result = grok.parse @email.not_nil! + email = result["email"]? + + if email.nil? + return Response::Error.new "invalid email format" + end + end + + # In this case we should not accept its registration. + if @password.size < 4 + return Response::Error.new "password too short" + end + + uid = authd.new_uid + password = authd.hash_password @password + + user = User.new uid, @login, password + user.contact.email = @email unless @email.nil? + user.contact.phone = @phone unless @phone.nil? + + @profile.try do |profile| + user.profile = profile + end + + user.date_registration = Time.local + + begin + field_subject = authd.configuration.field_subject.not_nil! + field_from = authd.configuration.field_from.not_nil! + activation_url = authd.configuration.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", field_subject, + "-f", field_from, + "-u", 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 + authd.users << user + + Response::UserAdded.new user.to_public + end + end + AuthD.requests << Register +end diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/search.cr b/zig-impl/crystal/some-crystal-app/authd/requests/search.cr new file mode 100644 index 0000000..b42f3b6 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/search.cr @@ -0,0 +1,34 @@ +class AuthD::Request + IPC::JSON.message SearchUser, 13 do + property user : String + + def initialize(@user) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + pattern = Regex.new @user, Regex::Options::IGNORE_CASE + + matching_users = Array(AuthD::User::Public).new + + users = authd.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 + end + end + AuthD.requests << SearchUser +end diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/token.cr b/zig-impl/crystal/some-crystal-app/authd/requests/token.cr new file mode 100644 index 0000000..6749285 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/token.cr @@ -0,0 +1,34 @@ +class AuthD::Request + IPC::JSON.message GetToken, 0 do + property login : String + property password : String + + def initialize(@login, @password) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + begin + user = authd.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 != authd.hash_password @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 + authd.users_per_uid.update user.uid.to_s, user + + Response::Token.new (token.to_s authd.configuration.shared_key), user.uid + end + end + AuthD.requests << GetToken +end diff --git a/zig-impl/crystal/some-crystal-app/authd/requests/users.cr b/zig-impl/crystal/some-crystal-app/authd/requests/users.cr new file mode 100644 index 0000000..0bf573b --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/requests/users.cr @@ -0,0 +1,84 @@ +class AuthD::Request + IPC::JSON.message ValidateUser, 2 do + property login : String + property activation_key : String + + def initialize(@login, @activation_key) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + user = authd.users_per_login.get? @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 == @activation_key + user.contact.activation_key = nil + else + return Response::Error.new "wrong activation key" + 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, 3 do + property user : Int32 | String + + def initialize(@user) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + uid_or_login = @user + user = if uid_or_login.is_a? Int32 + authd.users_per_uid.get? uid_or_login.to_s + else + authd.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 + end + end + AuthD.requests << GetUser + + IPC::JSON.message GetUserByCredentials, 4 do + property login : String + property password : String + + def initialize(@login, @password) + end + + def handle(authd : AuthD::Service, event : IPC::Event::Events) + user = authd.users_per_login.get? @login + + unless user + return Response::Error.new "invalid credentials" + end + + if authd.hash_password(@password) != user.password_hash + return Response::Error.new "invalid credentials" + end + + user.date_last_connection = Time.local + + # change the date of the last connection + authd.users_per_uid.update user.uid.to_s, user + + Response::User.new user.to_public + end + end + AuthD.requests << GetUserByCredentials +end diff --git a/zig-impl/crystal/some-crystal-app/authd/responses/contact.cr b/zig-impl/crystal/some-crystal-app/authd/responses/contact.cr new file mode 100644 index 0000000..8fb3285 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/responses/contact.cr @@ -0,0 +1,10 @@ +class AuthD::Response + IPC::JSON.message Contacts, 12 do + property user : Int32 + property email : String? = nil + property phone : String? = nil + def initialize(@user, @email, @phone) + end + end + AuthD.responses << Contacts +end diff --git a/zig-impl/crystal/some-crystal-app/authd/responses/errors.cr b/zig-impl/crystal/some-crystal-app/authd/responses/errors.cr new file mode 100644 index 0000000..a8fcd02 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/responses/errors.cr @@ -0,0 +1,8 @@ +class AuthD::Response + IPC::JSON.message Error, 0 do + property reason : String? = nil + def initialize(@reason) + end + end + AuthD.responses << Error +end diff --git a/zig-impl/crystal/some-crystal-app/authd/responses/password.cr b/zig-impl/crystal/some-crystal-app/authd/responses/password.cr new file mode 100644 index 0000000..4787e40 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/responses/password.cr @@ -0,0 +1,15 @@ +class AuthD::Response + IPC::JSON.message PasswordRecoverySent, 9 do + property user : ::AuthD::User::Public + def initialize(@user) + end + end + AuthD.responses << PasswordRecoverySent + + IPC::JSON.message PasswordRecovered, 10 do + property user : ::AuthD::User::Public + def initialize(@user) + end + end + AuthD.responses << PasswordRecovered +end diff --git a/zig-impl/crystal/some-crystal-app/authd/responses/permissions.cr b/zig-impl/crystal/some-crystal-app/authd/responses/permissions.cr new file mode 100644 index 0000000..cff5ce8 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/responses/permissions.cr @@ -0,0 +1,21 @@ +class AuthD::Response + IPC::JSON.message PermissionCheck, 7 do + property user : Int32 + 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 : Int32 + property service : String + property resource : String + property permission : ::AuthD::User::PermissionLevel + def initialize(@user, @service, @resource, @permission) + end + end + AuthD.responses << PermissionSet +end diff --git a/zig-impl/crystal/some-crystal-app/authd/responses/token.cr b/zig-impl/crystal/some-crystal-app/authd/responses/token.cr new file mode 100644 index 0000000..b3ebb3f --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/responses/token.cr @@ -0,0 +1,9 @@ +class AuthD::Response + IPC::JSON.message Token, 1 do + property uid : Int32 + property token : String + def initialize(@token, @uid) + end + end + AuthD.responses << Token +end diff --git a/zig-impl/crystal/some-crystal-app/authd/responses/users.cr b/zig-impl/crystal/some-crystal-app/authd/responses/users.cr new file mode 100644 index 0000000..be5373d --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/responses/users.cr @@ -0,0 +1,43 @@ +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 : Int32 + 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 +end diff --git a/zig-impl/crystal/some-crystal-app/authd/token.cr b/zig-impl/crystal/some-crystal-app/authd/token.cr new file mode 100644 index 0000000..5c8d844 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/token.cr @@ -0,0 +1,29 @@ +require "json" + +class AuthD::Token + include JSON::Serializable + + property login : String + property uid : Int32 + + def initialize(@login, @uid) + end + + def to_h + { + :login => login, + :uid => uid + } + end + + def to_s(key) + JWT.encode to_h, key, JWT::Algorithm::HS256 + end + + def self.from_s(key, str) + payload, meta = JWT.decode str, key, JWT::Algorithm::HS256 + + self.new payload["login"].as_s, payload["uid"].as_i + end +end + diff --git a/zig-impl/crystal/some-crystal-app/authd/user.cr b/zig-impl/crystal/some-crystal-app/authd/user.cr new file mode 100644 index 0000000..00246e9 --- /dev/null +++ b/zig-impl/crystal/some-crystal-app/authd/user.cr @@ -0,0 +1,76 @@ +require "json" + +require "uuid" + +require "./token.cr" + +class AuthD::User + include JSON::Serializable + + enum PermissionLevel + None + Read + Edit + Admin + + def to_json(o) + to_s.downcase.to_json o + end + end + + class Contact + include JSON::Serializable + + # the activation key is removed once the user is validated + property activation_key : String? + property email : String? + property phone : String? + + def initialize(@email = nil, @phone = nil) + @activation_key = UUID.random.to_s + end + end + + # Public. + property login : String + property uid : Int32 + property profile : Hash(String, JSON::Any)? + + # Private. + property contact : Contact + property password_hash : String + property password_renew_key : String? + # service => resource => permission level + property permissions : Hash(String, Hash(String, PermissionLevel)) + property configuration : Hash(String, Hash(String, JSON::Any)) + property date_last_connection : Time? = nil + property date_registration : Time? = nil + + def to_token + Token.new @login, @uid + end + + def initialize(@uid, @login, @password_hash) + @contact = Contact.new + @permissions = Hash(String, Hash(String, PermissionLevel)).new + @configuration = Hash(String, Hash(String, JSON::Any)).new + end + + class Public + include JSON::Serializable + + property login : String + property uid : Int32 + property profile : Hash(String, JSON::Any)? + + property date_registration : Time? + + def initialize(@uid, @login, @profile, @date_registration) + end + end + + def to_public : Public + Public.new @uid, @login, @profile, @date_registration + end +end +