Major update that includes various breaking changes.

- 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.
ipc07
Luka Vandervelden 2019-12-15 23:38:49 +01:00
parent 6a947402d7
commit b5c055b553
5 changed files with 393 additions and 144 deletions

View File

@ -6,6 +6,9 @@ require "ipc"
require "./user.cr" require "./user.cr"
class AuthD::Exception < Exception
end
class AuthD::Response class AuthD::Response
include JSON::Serializable include JSON::Serializable
@ -49,13 +52,13 @@ class AuthD::Response
end end
class User < Response class User < Response
property user : Passwd::User property user : ::AuthD::User::Public
initialize :user initialize :user
end end
class UserAdded < Response class UserAdded < Response
property user : Passwd::User property user : ::AuthD::User::Public
initialize :user initialize :user
end end
@ -66,28 +69,30 @@ class AuthD::Response
initialize :uid initialize :uid
end 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 class UsersList < Response
property users : Array(Passwd::User) property users : Array(::AuthD::User::Public)
initialize :users initialize :users
end 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. # This creates a Request::Type enumeration. One entry for each request type.
{% begin %} {% begin %}
enum Type enum Type
@ -175,18 +180,15 @@ class AuthD::Request
property login : String property login : String
property password : String property password : String
property uid : Int32? property profile : JSON::Any?
property gid : Int32?
property home : String?
property shell : String?
initialize :shared_key, :login, :password initialize :shared_key, :login, :password, :profile
end end
class GetUser < Request class GetUser < Request
property uid : Int32 property user : Int32 | String
initialize :uid initialize :user
end end
class GetUserByCredentials < Request class GetUserByCredentials < Request
@ -209,19 +211,9 @@ class AuthD::Request
class Request::Register < Request class Request::Register < Request
property login : String property login : String
property password : String property password : String
property profile : JSON::Any?
initialize :login, :password initialize :login, :password, :profile
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
end end
class Request::UpdatePassword < Request class Request::UpdatePassword < Request
@ -235,6 +227,29 @@ class AuthD::Request
property key : String? property key : String?
end 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. # This creates a Request::Type enumeration. One entry for each request type.
{% begin %} {% begin %}
enum Type enum Type
@ -315,13 +330,13 @@ module AuthD
end end
end end
def get_user?(uid : Int32) def get_user?(uid_or_login : Int32 | String) : ::AuthD::User::Public?
send Request::GetUser.new uid send Request::GetUser.new uid_or_login
response = read response = Response.from_ipc read
if response.type == Response::Type::Ok.value.to_u8 if response.is_a? Response::User
User.from_json String.new response.payload response.user
else else
nil nil
end end
@ -334,14 +349,14 @@ module AuthD
def decode_token(token) def decode_token(token)
user, meta = JWT.decode token, @key, JWT::Algorithm::HS256 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} {user, meta}
end end
# FIXME: Extra options may be useful to implement here. # FIXME: Extra options may be useful to implement here.
def add_user(login : String, password : String) : Passwd::User | Exception def add_user(login : String, password : String, profile : JSON::Any?) : ::AuthD::User::Public | Exception
send Request::AddUser.new @key, login, password send Request::AddUser.new @key, login, password, profile
response = Response.from_ipc read response = Response.from_ipc read
@ -349,7 +364,7 @@ module AuthD
when Response::UserAdded when Response::UserAdded
response.user response.user
when Response::Error when Response::Error
Exception.new response.reason raise Exception.new response.reason
else else
# Should not happen in serialized connections, but… # Should not happen in serialized connections, but…
# itll happen if you run several requests at once. # itll happen if you run several requests at once.
@ -357,6 +372,18 @@ module AuthD
end end
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 def mod_user(uid : Int32, password : String? = nil, avatar : String? = nil) : Bool | Exception
request = Request::ModUser.new @key, uid request = Request::ModUser.new @key, uid
@ -376,6 +403,40 @@ module AuthD
Exception.new "???" Exception.new "???"
end end
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
end end

View File

@ -3,7 +3,6 @@ require "option_parser"
require "openssl" require "openssl"
require "jwt" require "jwt"
require "passwd"
require "ipc" require "ipc"
require "dodb" require "dodb"
@ -14,59 +13,114 @@ extend AuthD
class AuthD::Service class AuthD::Service
property registrations_allowed = false 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 end
def handle_request(request : AuthD::Request?, connection : IPC::Connection) def handle_request(request : AuthD::Request?, connection : IPC::Connection)
case request case request
when Request::GetToken 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? if user.nil?
return Response::Error.new "invalid credentials" return Response::Error.new "invalid credentials"
end 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 when Request::AddUser
if request.shared_key != @jwt_key if request.shared_key != @jwt_key
return Response::Error.new "invalid authentication key" return Response::Error.new "invalid authentication key"
end end
if @passwd.user_exists? request.login if @users_per_login.get? request.login
return Response::Error.new "login already used" return Response::Error.new "login already used"
end 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 when Request::GetUserByCredentials
user = @passwd.get_user request.login, request.password user = @users_per_login.get request.login
if user unless user
Response::User.new user return Response::Error.new "invalid credentials"
else
Response::Error.new "user not found"
end 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 when Request::GetUser
user = @passwd.get_user request.uid uid_or_login = request.user
user = if uid_or_login.is_a? Int32
if user @users[uid_or_login.to_s]?
Response::User.new user
else else
Response::Error.new "user not found" @users_per_login.get? uid_or_login
end end
if user.nil?
return Response::Error.new "user not found"
end
Response::User.new user.to_public
when Request::ModUser when Request::ModUser
if request.shared_key != @jwt_key if request.shared_key != @jwt_key
return Response::Error.new "invalid authentication key" return Response::Error.new "invalid authentication key"
end end
password_hash = request.password.try do |s| user = @users[request.uid.to_s]?
Passwd.hash_password s
unless user
return Response::Error.new "user not found"
end 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 Response::UserEdited.new request.uid
when Request::Register when Request::Register
@ -74,48 +128,46 @@ class AuthD::Service
return Response::Error.new "registrations not allowed" return Response::Error.new "registrations not allowed"
end end
if @passwd.user_exists? request.login if @users_per_login.get? request.login
return Response::Error.new "login already used" return Response::Error.new "login already used"
end end
user = @passwd.add_user request.login, request.password uid = new_uid
password = hash_password request.password
Response::UserAdded.new user user = User.new uid, request.login, password
when Request::GetExtra
user = get_user_from_token request.token
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]? Response::UserAdded.new user.to_public
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
when Request::UpdatePassword 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 Response::UserEdited.new user.uid
when Request::ListUsers when Request::ListUsers
# FIXME: Lines too long, repeatedly (>80c with 4c tabs).
request.token.try do |token| request.token.try do |token|
user = get_user_from_token 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 end
request.key.try do |key| 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 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 else
Response::Error.new "unhandled request type" Response::Error.new "unhandled request type"
end end
end end
def get_user_from_token(token) def get_user_from_token(token : String)
user, meta = JWT.decode token, @jwt_key, JWT::Algorithm::HS256 token_payload = Token.from_s(token, @jwt_key)
Passwd::User.from_json user.to_json @users[token_payload.uid.to_s]?
end end
def run def run
@ -162,29 +267,19 @@ class AuthD::Service
end end
end end
authd_passwd_file = "passwd" authd_storage = "storage"
authd_group_file = "group"
authd_jwt_key = "nico-nico-nii" authd_jwt_key = "nico-nico-nii"
authd_registrations = false authd_registrations = false
authd_extra_storage = "storage"
OptionParser.parse do |parser| OptionParser.parse do |parser|
parser.on "-u file", "--passwd-file file", "passwd file." do |name| parser.on "-s directory", "--storage directory", "Directory in which to store users." do |directory|
authd_passwd_file = name authd_storage = directory
end
parser.on "-g file", "--group-file file", "group file." do |name|
authd_group_file = name
end end
parser.on "-K file", "--key-file file", "JWT key file" do |file_name| parser.on "-K file", "--key-file file", "JWT key file" do |file_name|
authd_jwt_key = File.read(file_name).chomp authd_jwt_key = File.read(file_name).chomp
end 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 parser.on "-R", "--allow-registrations" do
authd_registrations = true authd_registrations = true
end end
@ -196,9 +291,7 @@ OptionParser.parse do |parser|
end end
end end
passwd = Passwd.new authd_passwd_file, authd_group_file AuthD::Service.new(authd_storage, authd_jwt_key).tap do |authd|
AuthD::Service.new(passwd, authd_jwt_key, authd_extra_storage).tap do |authd|
authd.registrations_allowed = authd_registrations authd.registrations_allowed = authd_registrations
end.run end.run

52
src/storage.cr Normal file
View File

@ -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

31
src/token.cr Normal file
View File

@ -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

View File

@ -1,41 +1,53 @@
require "json" require "json"
require "passwd" require "./token.cr"
class Passwd::User class AuthD::User
JSON.mapping({ include JSON::Serializable
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?,
})
def sanitize! enum PermissionLevel
@password_hash = "x" None
self Read
Edit
Admin
def to_json(o)
to_s.downcase.to_json o
end
end end
def to_h # Public.
{ property login : String
:login => @login, property uid : Int32
:password_hash => "x", # Not real hash in JWT. property profile : JSON::Any?
:uid => @uid,
:gid => @gid, # Private.
:home => @home, property password_hash : String
:shell => @shell, property permissions : Hash(String, Hash(String, PermissionLevel))
:groups => @groups, property configuration : Hash(String, Hash(String, JSON::Any))
:full_name => @full_name,
:office_phone_number => @office_phone_number, def to_token
:home_phone_number => @home_phone_number, Token.new @login, @uid
:other_contact => @other_contact 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
end end