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.
This commit is contained in:
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"
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…
# itll 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

View File

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

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