module authentication import time import net.smtp import crypto.hmac import crypto.sha256 import crypto.rand import encoding.hex import encoding.base64 import log // Creates and updates, authenticates email authentication sessions @[noinit] pub struct Authenticator { secret string mut: config SmtpConfig @[required] backend IBackend // Backend for authenticator } // Is initialized when an auth link is sent // Represents the state of the authentication session pub struct AuthSession { pub mut: email string timeout time.Time auth_code string // hex representation of 64 bytes attempts_left int = 3 authenticated bool } @[params] pub struct AuthenticatorConfig { secret string smtp SmtpConfig backend IBackend } pub fn new(config AuthenticatorConfig) !Authenticator { // send email with link in body // mut client := smtp.new_client( // server: config.smtp.server // from: config.smtp.from // port: config.smtp.port // username: config.smtp.username // password: config.smtp.password // )! return Authenticator{ config: config.smtp // client: smtp.new_client( // server: config.smtp.server // from: config.smtp.from // port: config.smtp.port // username: config.smtp.username // password: config.smtp.password // )! backend: config.backend secret: config.secret } } @[params] pub struct SendMailConfig { email string mail VerificationMail link string } pub struct VerificationMail { pub: from string = 'email_authenticator@spiderlib.ff' subject string = 'Verify your email' body string = 'Please verify your email by clicking the link below' } pub struct SmtpConfig { server string from string port int username string password string } pub fn (mut auth Authenticator) email_authentication(config SendMailConfig) ! { auth.send_verification_mail(config)! auth.await_authentication(email: config.email)! } // sends mail with verification link pub fn (mut auth Authenticator) send_verification_mail(config SendMailConfig) ! { // create auth session auth_code := rand.bytes(64) or { panic(err) } auth.backend.create_auth_session( email: config.email auth_code: auth_code.hex() timeout: time.now().add_seconds(180) )! link := 'Click to authenticate' mail := smtp.Mail{ to: config.email from: config.mail.from subject: config.mail.subject body_type: .html body: '${config.mail.body}\n${link}' } mut client := smtp.new_client( server: auth.config.server from: auth.config.from port: auth.config.port username: auth.config.username password: auth.config.password )! client.send(mail) or { return error('Error resolving email address') } client.quit() or { return error('Could not close connection to server') } } // sends mail with login link pub fn (mut auth Authenticator) send_login_link(config SendMailConfig) ! { expiration := time.now().add(5 * time.minute) data := '${config.email}.${expiration}' // data to be signed signature := hmac.new(hex.decode(auth.secret) or { panic(err) }, data.bytes(), sha256.sum, sha256.block_size) encoded_signature := base64.url_encode(signature.bytestr().bytes()) link := 'Click to login' mail := smtp.Mail{ to: config.email from: config.mail.from subject: config.mail.subject body_type: .html body: '${config.mail.body}\n${link}' } mut client := smtp.new_client( server: auth.config.server from: auth.config.from port: auth.config.port username: auth.config.username password: auth.config.password )! client.send(mail) or { panic('Error resolving email address') } client.quit() or { panic('Could not close connection to server') } } pub struct LoginAttempt { pub: email string expiration time.Time signature string } // sends mail with login link pub fn (mut auth Authenticator) authenticate_login_attempt(attempt LoginAttempt) ! { if time.now() > attempt.expiration { return error('link expired') } data := '${attempt.email}.${attempt.expiration}' // data to be signed signature_mirror := hmac.new(hex.decode(auth.secret) or { panic(err) }, data.bytes(), sha256.sum, sha256.block_size).bytestr().bytes() decoded_signature := base64.url_decode(attempt.signature) if !hmac.equal(decoded_signature, signature_mirror) { return error('signature mismatch') } } // result of an authentication attempt // returns time and attempts remaining pub struct AttemptResult { pub: authenticated bool attempts_left int time_left time.Time } enum AuthErrorReason { cypher_mismatch no_remaining_attempts session_not_found } struct AuthError { Error reason AuthErrorReason } // authenticates if email/cypher combo correct within timeout and remaining attemts // TODO: address based request limits recognition to prevent brute // TODO: max allowed request per seccond to prevent dos pub fn (mut auth Authenticator) authenticate(email string, cypher string) ! { session := auth.backend.read_auth_session(email) or { return AuthError{ reason: .session_not_found } } if session.attempts_left <= 0 { // checks if remaining attempts return AuthError{ reason: .no_remaining_attempts } } // authenticates if cypher in link matches authcode if cypher == session.auth_code { auth.backend.set_session_authenticated(email) or { panic(err) } } else { updated_session := AuthSession{ ...session attempts_left: session.attempts_left - 1 } auth.backend.update_auth_session(updated_session)! return AuthError{ reason: .cypher_mismatch } } } pub struct AwaitAuthParams { email string @[required] timeout time.Duration = 3 * time.minute } // function to check if an email is authenticated pub fn (mut auth Authenticator) await_authentication(params AwaitAuthParams) ! { stopwatch := time.new_stopwatch() for stopwatch.elapsed() < params.timeout { if auth.is_authenticated(params.email)! { return } time.sleep(2 * time.second) } return error('Authentication timeout.') } // function to check if an email is authenticated pub fn (mut auth Authenticator) is_authenticated(email string) !bool { session := auth.backend.read_auth_session(email) or { return error('Cant find session') } return session.authenticated }