Added LDAP login support
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing

This commit is contained in:
Dominic Grimm 2022-02-06 16:42:08 +01:00
parent a0407f43bb
commit e55127f0bf
19 changed files with 175 additions and 252 deletions

View file

@ -23,4 +23,6 @@ BACKEND_SMTP_PASSWORD=
BACKEND_LDAP_HOST=
BACKEND_LDAP_PORT=
BACKEND_LDAP_BASE_DN=
BACKEND_BIND_DN=
BACKEND_BIND_PASSWORD=
BACKEND_LDAP_USER_DN=

View file

@ -74,6 +74,8 @@ services:
BACKEND_LDAP_HOST: ${BACKEND_LDAP_HOST}
BACKEND_LDAP_PORT: ${BACKEND_LDAP_PORT}
BACKEND_LDAP_BASE_DN: ${BACKEND_LDAP_BASE_DN}
BACKEND_LDAP_BIND_DN: ${BACKEND_LDAP_BIND_DN}
BACKEND_LDAP_BIND_PASSWORD: ${BACKEND_LDAP_BIND_PASSWORD}
BACKEND_LDAP_USER_DN: ${BACKEND_LDAP_USER_DN}
frontend:

View file

@ -1,21 +1,12 @@
-- +micrate Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TYPE user_roles AS ENUM ('Admin', 'Teacher', 'Student');
CREATE TYPE user_roles AS ENUM ('Teacher', 'Student');
CREATE TABLE users(
id BIGSERIAL PRIMARY KEY,
firstname TEXT NOT NULL,
lastname TEXT NOT NULL,
email TEXT NOT NULL,
PASSWORD TEXT NOT NULL,
username TEXT UNIQUE NOT NULL,
role user_roles NOT NULL,
blocked BOOLEAN NOT NULL,
UNIQUE (firstname, lastname, email)
);
CREATE TABLE admins(
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id)
admin BOOLEAN NOT NULL
);
CREATE TABLE teachers(
@ -31,11 +22,6 @@ CREATE TABLE students(
skif BOOLEAN NOT NULL
);
ALTER TABLE
users
ADD
COLUMN admin_id BIGINT UNIQUE REFERENCES admins(id);
ALTER TABLE
users
ADD

View file

@ -1,9 +1,5 @@
version: 2.0
shards:
CrystalEmail:
git: https://git.sceptique.eu/Sceptique/CrystalEmail.git
version: 0.2.6+git.commit.f217992c51048b3f94f4e064cd6c5123e32a1e27
bindata:
git: https://github.com/spider-gazelle/bindata.git
version: 1.9.1
@ -33,7 +29,7 @@ shards:
version: 0.6.3
env_config:
git: https://github.com/jreinert/env_config.cr.git
git: https://github.com/repomaa/env_config.cr.git
version: 0.1.0+git.commit.a3ef5b955f27e2c65de2fe0ff41718e2eea7c06f
fancyline:

View file

@ -26,9 +26,6 @@ dependencies:
branch: master
jwt:
github: crystal-community/jwt
CrystalEmail:
git: https://git.sceptique.eu/Sceptique/CrystalEmail.git
branch: master
commander:
github: mrrooijen/commander
fancyline:
@ -52,7 +49,7 @@ dependencies:
html-minifier:
github: sam0x17/html-minifier
env_config:
github: jreinert/env_config.cr
github: repomaa/env_config.cr
ldap:
github: spider-gazelle/crystal-ldap
ldap_escape:

View file

@ -12,11 +12,11 @@ module Backend
Argon2::Password.create(password)
end
def verify_password?(password : String, hash : String) : Bool
!!Argon2::Password.verify_password(password, hash)
rescue
false
end
# def verify_password?(password : String, hash : String) : Bool
# !!Argon2::Password.verify_password(password, hash)
# rescue
# false
# end
private def create_jwt(data, expiration : Int) : String
JWT.encode({"data" => data.to_h, "exp" => expiration}, Backend.config.api.jwt_secret, JWT::Algorithm::HS256)

View file

@ -6,11 +6,12 @@ module Backend
module Api
class Context < GraphQL::Context
getter user : Db::User?
getter admin : Bool?
getter role : Schema::UserRole?
getter external : (Db::Admin | Db::Teacher | Db::Student)?
getter external : (Db::Teacher | Db::Student)?
def initialize(request : HTTP::Request, *rest)
super(*rest)
def initialize(request : HTTP::Request, max_complexity : Int32? = nil)
super(max_complexity)
token = request.headers["Authorization"]?
if token && token[..Auth::BEARER.size - 1] == Auth::BEARER
@ -19,14 +20,13 @@ module Backend
data = payload["data"].as_h
@user = Db::User.find(data["user_id"].as_i)
return if @user.nil? || @user.not_nil!.blocked
return unless @user
if @user
@admin = @user.not_nil!.admin
@role = Schema::UserRole.parse(@user.not_nil!.role).not_nil!
@external =
case @role.not_nil!
when .admin?
@user.not_nil!.admin
when .teacher?
@user.not_nil!.teacher
when .student?
@ -37,7 +37,7 @@ module Backend
end
def authenticated? : Bool
!@user.nil?
!!@user
end
def authenticated! : Bool
@ -46,14 +46,22 @@ module Backend
true
end
def admin? : Bool
authenticated? && !!@admin
end
def admin! : Bool
raise "Invalid permissions" unless admin?
true
end
def role?(external = true, *roles : Schema::UserRole) : Bool
return false unless authenticated?
roles.each do |role|
return true if @role == role && if external
role == case @external
when Db::Admin
Schema::UserRole::Admin
role == case @external.not_nil!
when Db::Teacher
Schema::UserRole::Teacher
when Db::Student
@ -73,32 +81,45 @@ module Backend
true
end
private macro role_check(*roles)
{% for role in roles %}
{% name = role.names.last.underscore %}
def {{ name }}?(external = true) : Bool
role? external, {{ role }}
end
def {{ name }}!(external = true) : Bool
role! external, {{ role }}
end
{% end %}
end
role_check Schema::UserRole::Admin, Schema::UserRole::Teacher, Schema::UserRole::Student
def self.db_eq_role?(external : Granite::Base, role : Schema::UserRole) : Bool
role == case external
when Db::Admin
Schema::UserRole::Admin
when Db::Teacher
Schema::UserRole::Teacher
when Db::Student
Schema::UserRole::Student
end
def teacher?(external = false) : Bool
role? external, Schema::UserRole::Teacher
end
def teacher! : Bool
role! external, Schema::UserRole::Teacher
end
def student?(external = false) : Bool
role? external, Schema::UserRole::Student
end
def student! : Bool
role! external, Schema::UserRole::Student
end
# private macro role_check(*roles)
# {% for role in roles %}
# {% name = role.names.last.underscore %}
# def {{ name }}?(external = true) : Bool
# role? external, {{ role }}
# end
# def {{ name }}!(external = true) : Bool
# role! external, {{ role }}
# end
# {% end %}
# end
# role_check Schema::UserRole::Teacher, Schema::UserRole::Student
# def self.db_eq_role?(external : Granite::Base, role : Schema::UserRole) : Bool
# role == case external
# when Db::Teacher
# Schema::UserRole::Teacher
# when Db::Student
# Schema::UserRole::Student
# end
# end
end
end
end

View file

@ -1,26 +0,0 @@
module Backend
module Api
module Schema
@[GraphQL::Object]
class Admin < GraphQL::BaseObject
include Helpers::DbObject
db_object Db::Admin
@[GraphQL::Field]
def user : User
User.new(find!.user)
end
end
@[GraphQL::InputObject]
class AdminCreateInput < GraphQL::BaseInputObject
getter user_id
@[GraphQL::Field]
def initialize(@user_id : Int32)
end
end
end
end
end

View file

@ -1,4 +1,5 @@
require "CrystalEmail"
require "ldap"
require "socket"
module Backend
module Api
@ -6,11 +7,11 @@ module Backend
@[GraphQL::Object]
class Mutation < GraphQL::BaseMutation
@[GraphQL::Field]
def login(email : String, password : String) : LoginPayload
raise "Auth failed" if email.empty? || password.empty? || !CrystalEmail::Rfc5322::Public.validates?(email)
def login(username : String, password : String) : LoginPayload
raise "Auth failed" if username.empty? || password.empty?
user = Db::User.find_by(email: email)
raise "Auth failed" unless user && Auth.verify_password?(password, user.password)
user = Db::User.find_by(username: username)
raise "Auth failed" unless user && Ldap.authenticate?(Ldap.uid(username), password)
LoginPayload.new(
user: User.new(user),
@ -18,34 +19,28 @@ module Backend
)
end
@[GraphQL::Field]
def update_password(context : Context, password : String) : LoginPayload
context.authenticated!
# -> LDAP server
# @[GraphQL::Field]
# def update_password(context : Context, password : String) : LoginPayload
# context.authenticated!
if Auth.verify_password?(password, context.user.not_nil!.password)
raise "New password must be different from old password"
end
# if Auth.verify_password?(password, context.user.not_nil!.password)
# raise "New password must be different from old password"
# end
context.user.not_nil!.update!(password: Auth.hash_password(password))
# context.user.not_nil!.update!(password: Auth.hash_password(password))
LoginPayload.new(
user: User.new(context.user.not_nil!),
token: Auth.create_user_jwt(context.user.not_nil!.id.not_nil!.to_i),
)
end
# LoginPayload.new(
# user: User.new(context.user.not_nil!),
# token: Auth.create_user_jwt(context.user.not_nil!.id.not_nil!.to_i),
# )
# end
@[GraphQL::Field]
def create_user(context : Context, input : UserCreateInput) : User
context.admin!
user = Db::User.create!(
firstname: input.firstname,
lastname: input.lastname,
email: input.email,
password: Auth.hash_password(input.password),
role: input.role.to_s,
blocked: input.blocked,
)
user = Db::User.create!(username: input.username, role: input.role.to_s)
User.new(user)
end
@ -60,24 +55,6 @@ module Backend
id
end
@[GraphQL::Field]
def create_admin(context : Context, input : AdminCreateInput) : Admin
context.admin!
admin = Db::Admin.create!(user_id: input.user_id)
Admin.new(admin)
end
@[GraphQL::Field]
def delete_admin(context : Context, id : Int32) : Int32
context.admin!
admin = Db::Admin.find!(id)
admin.destroy!
id
end
@[GraphQL::Field]
def send_teachers_registration_email(context : Context) : Bool
context.admin!

View file

@ -30,17 +30,10 @@ module Backend
end
@[GraphQL::Field]
def admin(context : Context, id : Int32) : Admin
def admins(context : Context) : Array(User)
context.admin!
Admin.new(Db::Admin.find!(id))
end
@[GraphQL::Field]
def admins(context : Context) : Array(Admin)
context.admin!
Db::Admin.all.map { |admin| Admin.new(admin) }
Db::User.where(admin: true).map { |user| User.new(user) }
end
@[GraphQL::Field]

View file

@ -17,20 +17,33 @@ module Backend
find!.lastname
end
@[GraphQL::Field]
def name : String
find!.name
end
@[GraphQL::Field]
def username : String
find!.username
end
@[GraphQL::Field]
def email : String
find!.email
end
@[GraphQL::Field]
def admin : Bool
find!.admin
end
@[GraphQL::Field]
def role : UserRole
role = Db::UserRole.parse(find!.role)
case role
when Db::UserRole::Admin
UserRole::Admin
when Db::UserRole::Teacher
when .teacher?
UserRole::Teacher
when Db::UserRole::Student
when .student?
UserRole::Student
else
raise "Unknown role: #{role}"
@ -40,8 +53,6 @@ module Backend
@[GraphQL::Field]
def external_id : Int32?
case Db::UserRole.parse(find!.role)
when .admin?
find!.admin
when .teacher?
find!.teacher
when .student?
@ -51,14 +62,6 @@ module Backend
nil
end
@[GraphQL::Field]
def admin : Admin?
admin = find!.admin
if admin
Admin.new(admin)
end
end
@[GraphQL::Field]
def teacher : Teacher?
teacher = find!.teacher
@ -74,47 +77,21 @@ module Backend
Student.new(student)
end
end
@[GraphQL::Field]
def blocked : Bool
find!.blocked
end
end
@[GraphQL::InputObject]
class UserCreateInput < GraphQL::BaseInputObject
getter firstname
getter lastname
getter email
getter password
getter username
getter role
getter blocked
@[GraphQL::Field]
def initialize(
@firstname : String,
@lastname : String,
@email : String,
@password : String,
@role : UserRole,
@blocked : Bool = false
@username : String,
@role : UserRole
)
end
end
# @[GraphQL::InputObject]
# class LoginInput < GraphQL::BaseInputObject
# getter email
# getter password
# @[GraphQL::Field]
# def initialize(
# @email : String,
# @password : String
# )
# end
# end
@[GraphQL::Object]
class LoginPayload < GraphQL::BaseObject
property user

View file

@ -3,7 +3,6 @@ module Backend
module Schema
@[GraphQL::Enum]
enum UserRole
Admin
Teacher
Student
end

View file

@ -51,46 +51,39 @@ module Backend
end
cmd.commands.add do |c|
c.use = "seed"
c.use = "register <username> <role>"
c.long = "Seeds the database with required data"
c.run do
puts "Seeding database with admin user..."
data = {
"firstname" => input("Firstname: "),
"lastname" => input("Lastname: "),
"email" => input("Email: "),
"password" => Api::Auth.hash_password(input("Password: ")),
"role" => Db::UserRole::Admin.to_s,
}
password_confirmation = input("Password confirmation: ")
c.flags.add do |f|
f.name = "admin"
f.long = "--admin"
f.default = false
f.description = "Register as admin"
end
if data.values.any?(&.empty?)
abort "Values can't be empty!"
elsif !Api::Auth.verify_password?(password_confirmation, data["password"])
abort "Passwords do not match!"
end
c.run do |opts, args|
username = args[0]
role = Db::UserRole.parse(args[1].downcase)
print "Register '#{username}' as '#{role}'#{opts.bool["admin"] ? " with admin privileges" : nil}? [y/N] "
abort unless (gets(chomp: true) || "").strip.downcase == "y"
puts "---"
data.each { |k, v| puts "#{k.capitalize}: #{v}" }
puts "---"
unless input("Are you sure? (y/N) ").downcase == "y"
abort "Aborted!"
end
puts "Seeding database with admin user..."
user = Db::User.create!(data)
admin = Db::Admin.create!(user_id: user.id)
user = Db::User.create!(username: username, role: role.to_s, admin: opts.bool["admin"])
puts "Done!"
puts "---"
puts "User id: #{user.id}"
puts "Admin id: #{admin.id}"
puts "User: #{user.id}"
puts "Role: #{user.role}"
puts "Admin: #{user.admin}"
puts "---"
puts "Token: #{Api::Auth.create_user_jwt(user_id: user.id.not_nil!)}"
puts "---"
# puts "---"
# puts "User id: #{user.id}"
# puts "Admin id: #{admin.id}"
# puts "Token: #{Api::Auth.create_user_jwt(user_id: user.id.not_nil!)}"
# puts "---"
end
end
end

View file

@ -87,6 +87,8 @@ module Backend
getter host : String
getter port : Int32
getter base_dn : String
getter bind_dn : String
getter bind_password : String
getter user_dn : String
end
end

View file

@ -1,11 +0,0 @@
module Backend
module Db
class Admin < Granite::Base
table admins
belongs_to :user
column id : Int64, primary: true
end
end
end

View file

@ -1,28 +1,30 @@
require "CrystalEmail"
module Backend
module Db
class User < Granite::Base
table users
has_one :admin
has_one :teacher
has_one :student
column id : Int64, primary: true
column firstname : String
column lastname : String
column email : String
column password : String
column username : String
column role : String
column blocked : Bool = false
column admin : Bool = false
def name : String
"#{@firstname} #{@lastname}"
def firstname : String
Ldap.user(Ldap.uid(@username.not_nil!)).first["givenName"].first
end
validate :email, "needs to be an email address" do |user|
CrystalEmail::Rfc5322::Public.validates?(user.email)
def lastname : String
Ldap.user(Ldap.uid(@username.not_nil!)).first["sn"].first
end
def name : String
Ldap.user(Ldap.uid(@username.not_nil!)).first["cn"].first
end
def email : String
Ldap.user(Ldap.uid(@username.not_nil!)).first["mail"].first
end
validate :role, "needs to be a valid role" do |user|
@ -30,16 +32,14 @@ module Backend
end
validate :role, "user external needs to be a valid role" do |user|
if user.admin.nil? && user.teacher.nil? && user.student.nil?
if user.teacher.nil? && user.student.nil?
true
else
!!case UserRole.parse(user.role)
when UserRole::Admin
user.admin && user.teacher.nil? && user.student.nil?
when UserRole::Teacher
user.admin.nil? && user.teacher && user.student.nil?
when UserRole::Student
user.admin.nil? && user.teacher.nil? && user.student
when .teacher?
user.teacher && user.student.nil?
when .student?
user.teacher.nil? && user.student
else
false
end

View file

@ -1,7 +1,6 @@
module Backend
module Db
enum UserRole
Admin
Teacher
Student
end

View file

@ -10,8 +10,24 @@ module Backend
LDAP::Client.new(TCPSocket.new(Backend.config.ldap.host, Backend.config.ldap.port))
end
def user(username : String) : String
def cn(username : String) : String
"cn=#{LdapEscape.dn(username)},#{Backend.config.ldap.user_dn}"
end
def uid(uid : String) : String
"uid=#{LdapEscape.dn(uid)},#{Backend.config.ldap.user_dn}"
end
def user(dn : String) : Array(Hash(String, Array(String)))
create_client
.authenticate(Backend.config.ldap.bind_dn, Backend.config.ldap.bind_password)
.search(base: dn)
end
def authenticate?(dn : String, password : String) : Bool
!!create_client.authenticate(dn, password)
rescue LDAP::Client::AuthError
false
end
end
end

View file

@ -1,5 +1,5 @@
Hey, <%= user.name %>!
Hey, <%= user.firstname %>!
Du wurdest erfolgreich als Lehrer registriert.
Initialisiere deinen Account, indem du auf den folgenden Link klickst und deine Daten eingibst:
<%= Backend.config.url %>
<%= Path[Backend.config.url, "login"] %>