From b5c055b553466d72d887c41c55dcf740a7a951e6 Mon Sep 17 00:00:00 2001 From: Luka Vandervelden Date: Sun, 15 Dec 2019 23:38:49 +0100 Subject: [PATCH] Major update that includes various breaking changes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend is now a DODB::DataBase, not a passwd and group file anymore. - extras have been removed. A WIP User#profile field exists, that can be a JSON::Any. No profile validation has been implemented as of this commit. - authd now provides permission over resources, which is more precise than checking whether a user is part of a group. - permissions are now checked through authd once again: tokens don’t hold permissions anymore. - tokens are now minimal authentication “keys” to prove who you are and nothing more. --- src/authd.cr | 155 +++++++++++++++++++++++----------- src/main.cr | 223 +++++++++++++++++++++++++++++++++++-------------- src/storage.cr | 52 ++++++++++++ src/token.cr | 31 +++++++ src/user.cr | 76 ++++++++++------- 5 files changed, 393 insertions(+), 144 deletions(-) create mode 100644 src/storage.cr create mode 100644 src/token.cr diff --git a/src/authd.cr b/src/authd.cr index f5dba38..cf3d533 100644 --- a/src/authd.cr +++ b/src/authd.cr @@ -6,6 +6,9 @@ require "ipc" require "./user.cr" +class AuthD::Exception < Exception +end + class AuthD::Response include JSON::Serializable @@ -49,13 +52,13 @@ class AuthD::Response end class User < Response - property user : Passwd::User + property user : ::AuthD::User::Public initialize :user end class UserAdded < Response - property user : Passwd::User + property user : ::AuthD::User::Public initialize :user end @@ -66,28 +69,30 @@ class AuthD::Response initialize :uid end - class Extra < Response - property user : Int32 - property name : String - property extra : JSON::Any? - - initialize :user, :name, :extra - end - - class ExtraUpdated < Response - property user : Int32 - property name : String - property extra : JSON::Any? - - initialize :user, :name, :extra - end - class UsersList < Response - property users : Array(Passwd::User) + 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 + # This creates a Request::Type enumeration. One entry for each request type. {% begin %} enum Type @@ -175,18 +180,15 @@ class AuthD::Request property login : String property password : String - property uid : Int32? - property gid : Int32? - property home : String? - property shell : String? + property profile : JSON::Any? - initialize :shared_key, :login, :password + initialize :shared_key, :login, :password, :profile end class GetUser < Request - property uid : Int32 + property user : Int32 | String - initialize :uid + initialize :user end class GetUserByCredentials < Request @@ -209,19 +211,9 @@ class AuthD::Request class Request::Register < Request property login : String property password : String + property profile : JSON::Any? - initialize :login, :password - end - - class Request::GetExtra < Request - property token : String - property name : String - end - - class Request::SetExtra < Request - property token : String - property name : String - property extra : JSON::Any + initialize :login, :password, :profile end class Request::UpdatePassword < Request @@ -235,6 +227,29 @@ class AuthD::Request property key : String? end + class CheckPermission < Request + property shared_key : String + + # FIXME: Make it Int32 | String + property user : Int32 + property service : String + property resource : String + + initialize :shared_key, :user, :service, :resource + end + + class SetPermission < Request + property shared_key : String + + # FIXME: Make it Int32 | String + property user : Int32 + property service : String + property resource : String + property permission : ::AuthD::User::PermissionLevel + + initialize :shared_key, :user, :service, :resource, :permission + end + # This creates a Request::Type enumeration. One entry for each request type. {% begin %} enum Type @@ -315,13 +330,13 @@ module AuthD end end - def get_user?(uid : Int32) - send Request::GetUser.new uid + def get_user?(uid_or_login : Int32 | String) : ::AuthD::User::Public? + send Request::GetUser.new uid_or_login - response = read + response = Response.from_ipc read - if response.type == Response::Type::Ok.value.to_u8 - User.from_json String.new response.payload + if response.is_a? Response::User + response.user else nil end @@ -334,14 +349,14 @@ module AuthD def decode_token(token) user, meta = JWT.decode token, @key, JWT::Algorithm::HS256 - user = Passwd::User.from_json user.to_json + 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) : Passwd::User | Exception - send Request::AddUser.new @key, login, password + def add_user(login : String, password : String, profile : JSON::Any?) : ::AuthD::User::Public | Exception + send Request::AddUser.new @key, login, password, profile response = Response.from_ipc read @@ -349,7 +364,7 @@ module AuthD when Response::UserAdded response.user when Response::Error - Exception.new response.reason + raise Exception.new response.reason else # Should not happen in serialized connections, but… # it’ll happen if you run several requests at once. @@ -357,6 +372,18 @@ module AuthD end end + def register(login : String, password : String, profile : JSON::Any?) : ::AuthD::User::Public? + send Request::Register.new login, password, 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 : Int32, password : String? = nil, avatar : String? = nil) : Bool | Exception request = Request::ModUser.new @key, uid @@ -376,6 +403,40 @@ module AuthD Exception.new "???" end end + + def check_permission(user : ::AuthD::User::Public, service_name : String, resource_name : String) : User::PermissionLevel + request = Request::CheckPermission.new @key, user.uid, 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 end end diff --git a/src/main.cr b/src/main.cr index 33befe6..6d03720 100644 --- a/src/main.cr +++ b/src/main.cr @@ -3,7 +3,6 @@ require "option_parser" require "openssl" require "jwt" -require "passwd" require "ipc" require "dodb" @@ -14,59 +13,114 @@ extend AuthD class AuthD::Service property registrations_allowed = false - def initialize(@passwd : Passwd, @jwt_key : String, @extras_root : String) + @users_per_login : DODB::Index(User) + + def initialize(@storage_root : String, @jwt_key : String) + @users = DODB::DataBase(String, User).new @storage_root + @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?, connection : IPC::Connection) case request when Request::GetToken - user = @passwd.get_user request.login, request.password + user = @users_per_login.get request.login + + if user.password_hash != hash_password request.password + return Response::Error.new "invalid credentials" + end if user.nil? return Response::Error.new "invalid credentials" end - token = JWT.encode user.to_h, @jwt_key, JWT::Algorithm::HS256 + token = user.to_token - Response::Token.new token + Response::Token.new token.to_s @jwt_key when Request::AddUser if request.shared_key != @jwt_key return Response::Error.new "invalid authentication key" end - if @passwd.user_exists? request.login + if @users_per_login.get? request.login return Response::Error.new "login already used" end - user = @passwd.add_user request.login, request.password + password_hash = hash_password request.password - Response::UserAdded.new user + uid = new_uid + + user = User.new uid, request.login, password_hash + + request.profile.try do |profile| + user.profile = profile + end + + @users[user.uid.to_s] = user + + Response::UserAdded.new user.to_public when Request::GetUserByCredentials - user = @passwd.get_user request.login, request.password + user = @users_per_login.get request.login - if user - Response::User.new user - else - Response::Error.new "user not found" + unless user + return Response::Error.new "invalid credentials" end + + if hash_password(request.password) != user.password_hash + return Response::Error.new "invalid credentials" + end + + Response::User.new user.to_public when Request::GetUser - user = @passwd.get_user request.uid - - if user - Response::User.new user + uid_or_login = request.user + user = if uid_or_login.is_a? Int32 + @users[uid_or_login.to_s]? else - Response::Error.new "user not found" + @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 - password_hash = request.password.try do |s| - Passwd.hash_password s + user = @users[request.uid.to_s]? + + unless user + return Response::Error.new "user not found" end - @passwd.mod_user request.uid, password_hash: password_hash + password_hash = request.password.try do |s| + user.password_hash = hash_password s + end + + @users[user.uid.to_s] = user Response::UserEdited.new request.uid when Request::Register @@ -74,48 +128,46 @@ class AuthD::Service return Response::Error.new "registrations not allowed" end - if @passwd.user_exists? request.login + if @users_per_login.get? request.login return Response::Error.new "login already used" end - user = @passwd.add_user request.login, request.password + uid = new_uid + password = hash_password request.password - Response::UserAdded.new user - when Request::GetExtra - user = get_user_from_token request.token + user = User.new uid, request.login, password - return Response::Error.new "invalid token" unless user + request.profile.try do |profile| + user.profile = profile + end - storage = DODB::DataBase(String, JSON::Any).new "#{@extras_root}/#{user.uid}" + @users[user.uid.to_s] = user - Response::Extra.new user.uid, request.name, storage[request.name]? - when Request::SetExtra - user = get_user_from_token request.token - - return Response::Error.new "invalid token" unless user - - storage = DODB::DataBase(String, JSON::Any).new "#{@extras_root}/#{user.uid}" - - storage[request.name] = request.extra - - Response::ExtraUpdated.new user.uid, request.name, request.extra + Response::UserAdded.new user.to_public when Request::UpdatePassword - user = @passwd.get_user request.login, request.old_password + user = @users_per_login.get? request.login - return Response::Error.new "invalid credentials" unless user + unless user + return Response::Error.new "invalid credentials" + end - password_hash = Passwd.hash_password request.new_password + if hash_password(request.old_password) != user.password_hash + return Response::Error.new "invalid credentials" + end - @passwd.mod_user user.uid, password_hash: password_hash + user.password_hash = hash_password request.new_password + + @users[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)" unless user + return Response::Error.new "unauthorized (user not found from token)" - return Response::Error.new "unauthorized (user not in authd group)" unless user.groups.any? &.==("authd") + return Response::Error.new "unauthorized (user not in authd group)" unless user.permissions["authd"]?.try(&.["*"].>=(User::PermissionLevel::Read)) end request.key.try do |key| @@ -124,16 +176,69 @@ class AuthD::Service return Response::Error.new "unauthorized (no key nor token)" unless request.key || request.token - Response::UsersList.new @passwd.get_all_users + 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[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[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[user.uid.to_s] = user + + Response::PermissionSet.new user.uid, service, request.resource, request.permission else Response::Error.new "unhandled request type" end end - def get_user_from_token(token) - user, meta = JWT.decode token, @jwt_key, JWT::Algorithm::HS256 + def get_user_from_token(token : String) + token_payload = Token.from_s(token, @jwt_key) - Passwd::User.from_json user.to_json + @users[token_payload.uid.to_s]? end def run @@ -162,29 +267,19 @@ class AuthD::Service end end -authd_passwd_file = "passwd" -authd_group_file = "group" +authd_storage = "storage" authd_jwt_key = "nico-nico-nii" authd_registrations = false -authd_extra_storage = "storage" OptionParser.parse do |parser| - parser.on "-u file", "--passwd-file file", "passwd file." do |name| - authd_passwd_file = name - end - - parser.on "-g file", "--group-file file", "group file." do |name| - authd_group_file = name + 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 "-S dir", "--extra-storage dir", "Storage for extra user-data." do |directory| - authd_extra_storage = directory - end - parser.on "-R", "--allow-registrations" do authd_registrations = true end @@ -196,9 +291,7 @@ OptionParser.parse do |parser| end end -passwd = Passwd.new authd_passwd_file, authd_group_file - -AuthD::Service.new(passwd, authd_jwt_key, authd_extra_storage).tap do |authd| +AuthD::Service.new(authd_storage, authd_jwt_key).tap do |authd| authd.registrations_allowed = authd_registrations end.run diff --git a/src/storage.cr b/src/storage.cr new file mode 100644 index 0000000..e37d44e --- /dev/null +++ b/src/storage.cr @@ -0,0 +1,52 @@ +require "json" + +class AuthD::User + include JSON::Serializable + + property login : String + property password_hash : String? + property uid : Int32 + + property mail_address : String + + # FIXME: How would this profile be extended, replaced, checked? + property profile : Profile + + class Profile + include JSON::Serializable + + property full_name : String? + property description : String? + property avatar : String? + property website : String? + end + + property registration_date : Time + + property groups : Array(String) + + # application name => configuration object + property configuration : Hash(String, JSON::Any) +end + +class AuthD::Group + include JSON::Serializable + + property name : String + property gid : Int32 + property members : Array(String) +end + +class AuthD::Storage + # FIXME: Create new groups and users, generate their ids. + def initialize(@storage_root) + @users = DODB::Hash(Int32, User).new "#{@storage_root}/users" + @users_by_login = @users.new_index "login", &.login + @users_by_group = @users.new_tags "groups", &.groups + + @groups = DODB::Hash(Int32, Group).new "#{@storage_root}/groups" + @groups_by_name = new_index "name", &.name + @groups_by_member = new_tags "members", &.members + end +end + diff --git a/src/token.cr b/src/token.cr new file mode 100644 index 0000000..4427251 --- /dev/null +++ b/src/token.cr @@ -0,0 +1,31 @@ +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.to_json, key, JWT::Algorithm::HS256 + end + + def self.from_s(key, str) + payload, meta = JWT.decode str, key, JWT::Algorithm::HS256 + puts "PAYLOAD BELOW, BEWARE" + pp! payload + + self.new payload["login"].as_s, payload["uid"].as_i + end +end + diff --git a/src/user.cr b/src/user.cr index 6591033..5501df3 100644 --- a/src/user.cr +++ b/src/user.cr @@ -1,41 +1,53 @@ require "json" -require "passwd" +require "./token.cr" -class Passwd::User - JSON.mapping({ - login: String, - password_hash: String, - uid: Int32, - gid: Int32, - home: String, - shell: String, - groups: Array(String), - full_name: String?, - office_phone_number: String?, - home_phone_number: String?, - other_contact: String?, - }) +class AuthD::User + include JSON::Serializable - def sanitize! - @password_hash = "x" - self + enum PermissionLevel + None + Read + Edit + Admin + + def to_json(o) + to_s.downcase.to_json o + end end - def to_h - { - :login => @login, - :password_hash => "x", # Not real hash in JWT. - :uid => @uid, - :gid => @gid, - :home => @home, - :shell => @shell, - :groups => @groups, - :full_name => @full_name, - :office_phone_number => @office_phone_number, - :home_phone_number => @home_phone_number, - :other_contact => @other_contact - } + # Public. + property login : String + property uid : Int32 + property profile : JSON::Any? + + # Private. + property password_hash : String + property permissions : Hash(String, Hash(String, PermissionLevel)) + property configuration : Hash(String, Hash(String, JSON::Any)) + + def to_token + Token.new @login, @uid + end + + def initialize(@uid, @login, @password_hash) + @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 : JSON::Any? + + def initialize(@uid, @login, @profile) + end + end + + def to_public : Public + Public.new @uid, @login, @profile end end