diff --git a/shard.yml b/shard.yml index ed510ae..6ea6461 100644 --- a/shard.yml +++ b/shard.yml @@ -11,8 +11,6 @@ description: | targets: authd: main: src/main.cr - authd-adduser: - main: src/adduser.cr crystal: 0.27 @@ -23,9 +21,5 @@ dependencies: jwt: github: crystal-community/jwt branch: master - pg: - github: will/crystal-pg - crecto: - github: fridgerator/crecto license: EUPL diff --git a/src/adduser.cr b/src/adduser.cr deleted file mode 100644 index 17dc19d..0000000 --- a/src/adduser.cr +++ /dev/null @@ -1,89 +0,0 @@ - -require "option_parser" - -require "./user.cr" - -user_name : String? = nil -user_password : String? = nil -user_perms = Array(String).new - -database_url = "postgres:localhost" -database_name = "authd" -database_user = "nico" -database_password = "nico-nico-nii" - -OptionParser.parse! do |parser| - parser.banner = "usage: #{ARGV[0]} [arguments]" - - parser.on "-a url", "--database url", "URL of authd’s database." do |url| - database_url = url - end - parser.on "-U name", "--database-user name", "Name of the database’s user." do |name| - database_user = name - end - parser.on "-P password", "--database-pasword password", "Password of the database’s user." do |password| - database_password = password - end - parser.on "-N name", "--database-name name", "Name of the database." do |name| - database_name = name - end - - parser.on "-u name", "--username name", "Name of the user to add." do |name| - user_name = name - end - parser.on "-p password", "--password password", "Password of the user." do |password| - user_password = password - end - parser.on "-x perm", "--permission", "Add a permission token to the user." do |token| - user_perms << token - end - - parser.on("-h", "--help", "Show this help") do - puts parser - exit 0 - end - - parser.invalid_option do |flag| - puts "error: #{flag} is not a valid option." - puts parser - - exit 1 - end -end - -module DataBase - extend Crecto::Repo -end - -DataBase.config do |conf| - conf.adapter = Crecto::Adapters::Postgres - conf.hostname = database_url - conf.username = database_user - conf.password = database_password - conf.database = database_name -end - -if user_name.nil? - STDERR.puts "No username specified." - exit 1 -end -if user_password.nil? - STDERR.puts "No user password specified." - exit 1 -end - -user = AuthD::User.new -user.username = user_name -user.password = user_password -user.perms = user_perms -changeset = DataBase.insert user - -if changeset.valid? - puts "User added successfully." -else - changeset.errors.each do |e| - p e - end -end - - diff --git a/src/authd.cr b/src/authd.cr index f94c718..132d0ee 100644 --- a/src/authd.cr +++ b/src/authd.cr @@ -4,25 +4,40 @@ require "jwt" require "ipc" require "./user.cr" +require "./group.cr" module AuthD enum RequestTypes - GET_TOKEN + GetToken + AddUser end enum ResponseTypes - OK - MALFORMED_REQUEST - INVALID_CREDENTIALS + Ok + MalformedRequest + InvalidCredentials + InvalidUser end class GetTokenRequest JSON.mapping({ - username: String, + # FIXME: Rename to "login" for consistency. + login: String, password: String }) end + class AddUserRequest + JSON.mapping({ + login: String, + password: String, + uid: Int32?, + gid: Int32?, + home: String?, + shell: String? + }) + end + class Client < IPC::Client property key : String @@ -32,21 +47,25 @@ module AuthD initialize "auth" end - def get_token?(username : String, password : String) - send RequestTypes::GET_TOKEN.value.to_u8, { - :username => username, + def get_token?(login : String, password : String) + send RequestTypes::GetToken.value.to_u8, { + :login => login, :password => password }.to_json response = read - if response.type == ResponseTypes::OK.value.to_u8 + if response.type == ResponseTypes::Ok.value.to_u8 response.payload else nil end end + def send(type : RequestTypes, payload) + send type.value.to_u8, payload + end + def decode_token(token) user, meta = JWT.decode token, @key, "HS256" @@ -54,6 +73,24 @@ module AuthD {user, meta} end + + # FIXME: Extra options may be useful to implement here. + def add_user(login : String, password : String) : AuthD::User | Exception + send RequestTypes::AddUser, { + :login => login, + :password => password + }.to_json + + response = read + + pp! response.type + case ResponseTypes.new response.type.to_i + when ResponseTypes::Ok + AuthD::User.from_json response.payload + else + Exception.new response.payload + end + end end end diff --git a/src/group.cr b/src/group.cr new file mode 100644 index 0000000..8a64189 --- /dev/null +++ b/src/group.cr @@ -0,0 +1,11 @@ + +class AuthD::Group + getter name : String + getter password_hash : String + getter gid : Int32 + getter users = Array(String).new + + def initialize(@name, @password_hash, @gid, @users) + end +end + diff --git a/src/main.cr b/src/main.cr index 6ab8a6a..3bca688 100644 --- a/src/main.cr +++ b/src/main.cr @@ -9,30 +9,27 @@ require "crecto" require "ipc" require "./authd.cr" +require "./passwd.cr" extend AuthD -authd_db_name = "authd" -authd_db_hostname = "localhost" -authd_db_user = "user" -authd_db_password = "nico-nico-nii" +class IPC::RemoteClient + def send(type : ResponseTypes, payload : String) + send type.value.to_u8, payload + end +end + +authd_passwd_file = "passwd" +authd_group_file = "group" authd_jwt_key = "nico-nico-nii" OptionParser.parse! do |parser| - parser.on "-d name", "--database-name name", "Database name." do |name| - authd_db_name = name + parser.on "-u file", "--passwd-file file", "passwd file." do |name| + authd_passwd_file = name end - parser.on "-u name", "--database-username user", "Database user." do |name| - authd_db_user = name - end - - parser.on "-a host", "--hostname host", "Database host name." do |host| - authd_db_hostname = host - end - - parser.on "-P file", "--password-file file", "Password file." do |file_name| - authd_db_password = File.read(file_name).chomp + parser.on "-g file", "--group-file file", "group file." do |name| + authd_group_file = name end parser.on "-K file", "--key-file file", "JWT key file" do |file_name| @@ -46,26 +43,7 @@ OptionParser.parse! do |parser| end end -module DataBase - extend Crecto::Repo -end - -DataBase.config do |conf| - conf.adapter = Crecto::Adapters::Postgres - conf.hostname = authd_db_hostname - conf.database = authd_db_name - conf.username = authd_db_user - conf.password = authd_db_password -end - -# Dummy query to check DB connection is possible. -begin - DataBase.all User, Crecto::Repo::Query.new -rescue e - puts "Database connection failed: #{e.message}" - - exit 1 -end +passwd = Passwd.new authd_passwd_file, authd_group_file ## # Provides a JWT-based authentication scheme for service-specific users. @@ -78,27 +56,43 @@ IPC::Service.new "auth" do |event| payload = message.payload case RequestTypes.new message.type.to_i - when RequestTypes::GET_TOKEN + when RequestTypes::GetToken begin request = GetTokenRequest.from_json payload rescue e - client.send ResponseTypes::MALFORMED_REQUEST.value.to_u8, e.message || "" + client.send ResponseTypes::MalformedRequest.value.to_u8, e.message || "" next end - user = DataBase.get_by User, - username: request.username, - password: request.password + user = passwd.get_user request.login, request.password if user.nil? - client.send ResponseTypes::INVALID_CREDENTIALS.value.to_u8, "" + client.send ResponseTypes::InvalidCredentials.value.to_u8, "" next end - client.send ResponseTypes::OK.value.to_u8, + client.send ResponseTypes::Ok.value.to_u8, JWT.encode user.to_h, authd_jwt_key, "HS256" + when RequestTypes::AddUser + begin + request = AddUserRequest.from_json payload + rescue e + client.send ResponseTypes::MalformedRequest.value.to_u8, e.message || "" + + next + end + + if passwd.user_exists? request.login + client.send ResponseTypes::InvalidUser, "Another user with the same login already exists." + + next + end + + user = passwd.add_user request.login, request.password + + client.send ResponseTypes::Ok, user.to_json end end end diff --git a/src/passwd.cr b/src/passwd.cr new file mode 100644 index 0000000..6811d60 --- /dev/null +++ b/src/passwd.cr @@ -0,0 +1,227 @@ +require "csv" +require "uuid" + +require "./user.cr" +require "./group.cr" + +# FIXME: Should we work on arrays and convert to CSV at the last second when adding rows? + +class Passwd + @passwd : String + @group : String + + # FIXME: Missing operations: + # - Removing users. + # - Reading groups. + # - Adding and removing groups (ok, maybe admins can do that?) + # - Adding users to group. + # - Removing users from group. + + # FIXME: Safety. Ensure passwd and group cannot be in a half-written state. (backups will probably work well enough in the mean-time) + + def initialize(@passwd, @group) + end + + private def passwd_as_array + CSV.parse File.read(@passwd), separator: ':' + end + + private def group_as_array + CSV.parse File.read(@group), separator: ':' + end + + private def set_user_groups(user : AuthD::User) + group_as_array.each do |line| + group = AuthD::Group.new line + + if group.users.any? { |name| name == user.login } + user.groups << group.name + end + + pp group + end + end + + def each_user(&block) + passwd_as_array.each do |line| + yield AuthD::User.new line + end + end + + def user_exists?(login : String) : Bool + each_user do |user| + return true if user.login == login + end + + false + end + + def get_user(uid : Int32) : AuthD::User? + each_user do |user| + if user.uid == uid + # FIXME: Check user groups and register them here. + set_user_groups user + + return user + end + end + end + + ## + # Will fail if the user is found but the password is invalid. + def get_user(login : String, password : String) : AuthD::User? + digest = OpenSSL::Digest.new("sha256") + digest << password + hash = digest.hexdigest + + each_user do |user| + if user.login == login + # FIXME: XXX: HASH!!!!! + if user.password_hash == hash + return user + end + + next + end + end + end + + def get_all_users + users = Array(AuthD::User).new + + passwd_as_array.each do |line| + users << AuthD::User.new line + end + + users + end + + def get_all_groups + groups = Array(AuthD::Group).new + + group_as_array.each do |line| + groups << AuthD::Group.new line + end + + groups + end + + def to_passwd + get_all_users.map(&.to_csv).join("\n") + "\n" + end + + def to_group + get_all_groups.map(&.to_csv).join("\n") + "\n" + end + + private def get_free_uid + uid = 1000 + + users = get_all_users + + while users.any? &.uid.== uid + uid += 1 + end + + uid + end + + private def get_free_gid + gid = 1000 + + users = get_all_users + + while users.any? &.gid.== gid + gid += 1 + end + + gid + end + + def add_user(login, password = nil, uid = nil, gid = nil, home = "/", shell = "/bin/nologin") + # FIXME: If user already exists, exception? Replacement? + + uid = get_free_uid if uid.nil? + + gid = get_free_gid if gid.nil? + + password_hash = if password + digest = OpenSSL::Digest.new("sha256") + digest << password + digest.hexdigest + else + "x" + end + + user = AuthD::User.new login, password_hash, uid, gid, home, shell + + File.write(@passwd, user.to_csv + "\n", mode: "a") + + add_group login, gid: gid, users: [user.login] + + set_user_groups user + + user + end + + def add_group(name, password_hash = "x", gid = nil, users = Array(String).new) + gid = get_free_gid if gid.nil? + + group = AuthD::Group.new name, password_hash, gid, users + + File.write(@group, group.to_csv + "\n", mode: "a") + end +end + +class AuthD::Group + def initialize(line : Array(String)) + @name = line[0] + @password_hash = line[1] + @gid = line[2].to_i + + @users = line[3].split "," + end + + def to_csv + [@name, @password_hash, @gid, @users.join ","].join ":" + end +end + +class AuthD::User + def initialize(line : Array(String)) + @login = line[0] + @password_hash = line[1] + + @uid = line[2].to_i + @gid = line[3].to_i + + CSV.parse(line[4], separator: ',')[0]?.try do |gecos| + @full_name = gecos[0]? + @location = gecos[1]? + @office_phone_number = gecos[2]? + @home_phone_number = gecos[3]? + @other_contact = gecos[4]? + end + + # FIXME: What about those two fields? Keep them, remove them? + @home = line[5] + @shell = line[6] + end + + def to_csv + [@login, @password_hash, @uid, @gid, gecos, @home, @shell].join ":" + end + + def gecos + unless @location || @office_phone_number || @home_phone_number || @other_contact + if @full_name + return @full_name + else + return "" + end + end + + [@full_name || "", @location || "", @office_phone_number || "", @home_phone_number || "", @other_contact || ""].join "," + end +end + diff --git a/src/user.cr b/src/user.cr index df50ac2..6d2e95d 100644 --- a/src/user.cr +++ b/src/user.cr @@ -1,24 +1,51 @@ -require "pg" -require "crecto" +class AuthD::User + getter login : String + getter password_hash : String + getter uid : Int32 + getter gid : Int32 + getter home : String = "/" + getter shell : String = "/bin/nologin" + getter groups = Array(String).new + getter full_name : String? = nil + getter avatar : String? = nil + getter location : String? = nil + getter office_phone_number : String? = nil + getter home_phone_number : String? = nil + getter other_contact : String? = nil -class AuthD::User < Crecto::Model - schema "users" do # table name - field :username, String - field :realname, String - field :avatar, String - field :password, String - field :perms, Array(String) + JSON.mapping({ + login: String, + password_hash: String, + uid: Int32, + gid: Int32, + home: String, + shell: String, + groups: Array(String), + full_name: String?, + avatar: String?, + office_phone_number: String?, + home_phone_number: String?, + other_contact: String? + }) + + def initialize(@login, @password_hash, @uid, @gid, @home, @shell) end - validate_required [:username, :password, :perms] - def to_h { - :username => @username, - :realname => @realname, - :perms => @perms, - :avatar => @avatar + :login => @login, + :password_hash => "x", # Not real hash in JWT. + :uid => @uid, + :gid => @gid, + :home => @home, + :shell => @shell, + :groups => @groups, + :full_name => @full_name, + :avatar => @avatar, + :office_phone_number => @office_phone_number, + :home_phone_number => @home_phone_number, + :other_contact => @other_contact } end end