move auth and jwt modules to herolib
This commit is contained in:
9
lib/security/authentication/README.md
Normal file
9
lib/security/authentication/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Email authentication module
|
||||
|
||||
Module to verify user email by sending the user a link.The functions in the module can be implemented manually in a web server, but the recommended way is simply to use the API.
|
||||
|
||||
## API
|
||||
|
||||
## Examples
|
||||
|
||||
- see publisher/view/auth_controllers
|
||||
245
lib/security/authentication/authenticator.v
Normal file
245
lib/security/authentication/authenticator.v
Normal file
@@ -0,0 +1,245 @@
|
||||
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 := '<a href="${config.link}/${config.email}/${auth_code.hex()}">Click to authenticate</a>'
|
||||
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 := '<a href="${config.link}/${config.email}/${expiration.unix()}/${encoded_signature}">Click to login</a>'
|
||||
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
|
||||
}
|
||||
14
lib/security/authentication/backend.v
Normal file
14
lib/security/authentication/backend.v
Normal file
@@ -0,0 +1,14 @@
|
||||
module authentication
|
||||
|
||||
import log
|
||||
|
||||
// Creates and updates, authenticates email authentication sessions
|
||||
interface IBackend {
|
||||
read_auth_session(string) ?AuthSession
|
||||
mut:
|
||||
logger &log.Logger
|
||||
create_auth_session(AuthSession) !
|
||||
update_auth_session(AuthSession) !
|
||||
delete_auth_session(string) !
|
||||
set_session_authenticated(string) !
|
||||
}
|
||||
93
lib/security/authentication/backend_database.v
Normal file
93
lib/security/authentication/backend_database.v
Normal file
@@ -0,0 +1,93 @@
|
||||
module authentication
|
||||
|
||||
import db.sqlite
|
||||
import log
|
||||
import time
|
||||
|
||||
// Creates and updates, authenticates email authentication sessions
|
||||
@[noinit]
|
||||
struct DatabaseBackend {
|
||||
mut:
|
||||
db sqlite.DB
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct DatabaseBackendConfig {
|
||||
db_path string = 'email_authenticator.sqlite'
|
||||
}
|
||||
|
||||
// factory for
|
||||
pub fn new_database_backend(config DatabaseBackendConfig) !DatabaseBackend {
|
||||
db := sqlite.connect(config.db_path) or { panic(err) }
|
||||
|
||||
sql db {
|
||||
create table AuthSession
|
||||
} or { panic(err) }
|
||||
|
||||
return DatabaseBackend{
|
||||
// logger: config.logger
|
||||
db: db
|
||||
}
|
||||
}
|
||||
|
||||
pub fn (auth DatabaseBackend) create_auth_session(session_ AuthSession) ! {
|
||||
mut session := session_
|
||||
if session.timeout.unix() == 0 {
|
||||
session.timeout = time.now().add_seconds(180)
|
||||
}
|
||||
sql auth.db {
|
||||
insert session into AuthSession
|
||||
} or { panic('err:${err}') }
|
||||
}
|
||||
|
||||
pub fn (auth DatabaseBackend) read_auth_session(email string) ?AuthSession {
|
||||
session := sql auth.db {
|
||||
select from AuthSession where email == '${email}'
|
||||
} or { panic('err:${err}') }
|
||||
return session[0] or { return none }
|
||||
}
|
||||
|
||||
pub fn (auth DatabaseBackend) update_auth_session(session AuthSession) ! {
|
||||
sql auth.db {
|
||||
update AuthSession set attempts_left = session.attempts_left where email == session.email
|
||||
} or { panic('err:${err}') }
|
||||
}
|
||||
|
||||
pub fn (auth DatabaseBackend) set_session_authenticated(email string) ! {
|
||||
sql auth.db {
|
||||
update AuthSession set authenticated = true where email == email
|
||||
} or { panic('err:${err}') }
|
||||
}
|
||||
|
||||
pub fn (auth DatabaseBackend) delete_auth_session(email string) ! {
|
||||
sql auth.db {
|
||||
delete from AuthSession where email == '${email}'
|
||||
} or { panic('err:${err}') }
|
||||
}
|
||||
|
||||
// if session.attempts_left <= 0 { // checks if remaining attempts
|
||||
// return AttemptResult{
|
||||
// authenticated: false
|
||||
|
||||
// attempts_left: 0
|
||||
// time_left:
|
||||
// }
|
||||
// }
|
||||
|
||||
// // authenticates if cypher in link matches authcode
|
||||
// if cypher == auth.sessions[email].auth_code {
|
||||
// auth.logger.debug(@FN + ':\nUser authenticated email: ${email}')
|
||||
// auth.sessions[email].authenticated = true
|
||||
// result := AttemptResult{
|
||||
// authenticated: true
|
||||
// attempts_left: auth.sessions[email].attempts_left
|
||||
// }
|
||||
// return result
|
||||
// } else {
|
||||
// auth.sessions[email].attempts_left -= 1
|
||||
// result := AttemptResult{
|
||||
// authenticated: false
|
||||
// attempts_left: auth.sessions[email].attempts_left
|
||||
// }
|
||||
// return result
|
||||
// }
|
||||
59
lib/security/authentication/backend_test.v
Normal file
59
lib/security/authentication/backend_test.v
Normal file
@@ -0,0 +1,59 @@
|
||||
module authentication
|
||||
|
||||
import db.sqlite
|
||||
import log
|
||||
import time
|
||||
|
||||
const test_email = 'test@example.com'
|
||||
|
||||
const test_auth_code = '123ABC'
|
||||
|
||||
const test_db_name = 'email_authenticator.sqlite'
|
||||
|
||||
fn testsuite_begin() {
|
||||
db := sqlite.connect(email.test_db_name) or { panic(err) }
|
||||
sql db {
|
||||
drop table AuthSession
|
||||
} or { return }
|
||||
}
|
||||
|
||||
fn testsuite_end() {
|
||||
db := sqlite.connect(email.test_db_name) or { panic(err) }
|
||||
sql db {
|
||||
drop table AuthSession
|
||||
} or { panic(err) }
|
||||
}
|
||||
|
||||
fn test_database_backend() {
|
||||
mut backend := new_database_backend()!
|
||||
run_backend_tests(mut backend)!
|
||||
backend.db.close()!
|
||||
}
|
||||
|
||||
fn test_memory_backend() {
|
||||
mut backend := new_memory_backend()!
|
||||
run_backend_tests(mut backend)!
|
||||
}
|
||||
|
||||
fn run_backend_tests(mut backend IBackend) ! {
|
||||
session := AuthSession{
|
||||
email: email.test_email
|
||||
}
|
||||
|
||||
backend.create_auth_session(session)!
|
||||
assert backend.read_auth_session(email.test_email)! == session
|
||||
|
||||
backend.update_auth_session(AuthSession{
|
||||
...session
|
||||
attempts_left: 1
|
||||
})!
|
||||
assert backend.read_auth_session(email.test_email)!.attempts_left == 1
|
||||
|
||||
backend.delete_auth_session(email.test_email)!
|
||||
if _ := backend.read_auth_session(email.test_email) {
|
||||
// should return none, so fails test
|
||||
assert false
|
||||
} else {
|
||||
assert true
|
||||
}
|
||||
}
|
||||
68
lib/security/authentication/client.v
Normal file
68
lib/security/authentication/client.v
Normal file
@@ -0,0 +1,68 @@
|
||||
module authentication
|
||||
|
||||
import net.http
|
||||
import time
|
||||
import json
|
||||
|
||||
// session controller that be be added to vweb projects
|
||||
pub struct EmailClient {
|
||||
url string @[required]
|
||||
}
|
||||
|
||||
struct PostParams {
|
||||
url string
|
||||
data string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
fn (client EmailClient) post_request(params PostParams) !http.Response {
|
||||
mut req := http.new_request(http.Method.post, params.url, params.data)
|
||||
req.read_timeout = params.timeout
|
||||
resp := req.do() or {
|
||||
return error('Failed to send request to email authentication server: ${err.code}')
|
||||
}
|
||||
if resp.status_code == 404 {
|
||||
return error('Could not find email verification endpoint, please make sure the auth client url is configured to the url the auth server is running at.')
|
||||
}
|
||||
if resp.status_code != 200 {
|
||||
panic('Email verification request failed, this should never happen: ${resp.status_msg}')
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// verify_email posts an email verification req to the email auth controller
|
||||
pub fn (client EmailClient) email_authentication(params SendMailConfig) ! {
|
||||
client.post_request(
|
||||
url: '${client.url}/email_authentication'
|
||||
data: json.encode(params)
|
||||
timeout: 180 * time.second
|
||||
)!
|
||||
}
|
||||
|
||||
// verify_email posts an email verification req to the email auth controller
|
||||
pub fn (client EmailClient) is_verified(address string) !bool {
|
||||
resp := client.post_request(
|
||||
url: '${client.url}/is_verified'
|
||||
data: json.encode(address)
|
||||
timeout: 180 * time.second
|
||||
)!
|
||||
return resp.body == 'true'
|
||||
}
|
||||
|
||||
// send_verification_email posts an email verification req to the email auth controller
|
||||
pub fn (client EmailClient) send_verification_email(params SendMailConfig) ! {
|
||||
client.post_request(
|
||||
url: '${client.url}/send_verification_mail'
|
||||
data: json.encode(params)
|
||||
) or { return error(err.msg()) }
|
||||
}
|
||||
|
||||
// authenticate posts an authentication attempt req to the email auth controller
|
||||
pub fn (c EmailClient) authenticate(address string, cypher string) !AttemptResult {
|
||||
resp := http.post('${c.url}/authenticate', json.encode(AuthAttempt{
|
||||
address: address
|
||||
cypher: cypher
|
||||
}))!
|
||||
result := json.decode(AttemptResult, resp.body)!
|
||||
return result
|
||||
}
|
||||
145
lib/security/authentication/controller.v
Normal file
145
lib/security/authentication/controller.v
Normal file
@@ -0,0 +1,145 @@
|
||||
module authentication
|
||||
|
||||
import vweb
|
||||
import time
|
||||
import json
|
||||
import log
|
||||
import freeflowuniverse.herolib.ui.console
|
||||
|
||||
const agent = 'Email Authentication Controller'
|
||||
|
||||
// email authentication controller that be be added to vweb projects
|
||||
@[heap]
|
||||
pub struct Controller {
|
||||
vweb.Context
|
||||
callback string @[vweb_global]
|
||||
mut:
|
||||
authenticator Authenticator @[vweb_global]
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct ControllerParams {
|
||||
logger &log.Logger
|
||||
authenticator Authenticator @[required]
|
||||
}
|
||||
|
||||
pub fn new_controller(params ControllerParams) Controller {
|
||||
mut app := Controller{
|
||||
authenticator: params.authenticator
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
// route responsible for verifying email, email form should be posted here
|
||||
@[POST]
|
||||
pub fn (mut app Controller) send_verification_mail() !vweb.Result {
|
||||
config := json.decode(SendMailConfig, app.req.data)!
|
||||
app.authenticator.send_verification_mail(config) or { panic(err) }
|
||||
return app.ok('')
|
||||
}
|
||||
|
||||
// route responsible for verifying email, email form should be posted here
|
||||
@[POST]
|
||||
pub fn (mut app Controller) is_verified() vweb.Result {
|
||||
address := app.req.data
|
||||
// checks if email verified every 2 seconds
|
||||
for {
|
||||
if app.authenticator.is_authenticated(address) or { panic(err) } {
|
||||
// returns success message once verified
|
||||
return app.ok('ok')
|
||||
}
|
||||
time.sleep(2 * time.second)
|
||||
}
|
||||
return app.html('timeout')
|
||||
}
|
||||
|
||||
// route responsible for verifying email, email form should be posted here
|
||||
@[POST]
|
||||
pub fn (mut app Controller) email_authentication() vweb.Result {
|
||||
config_ := json.decode(SendMailConfig, app.req.data) or {
|
||||
app.set_status(422, 'Request payload does not follow anticipated formatting.')
|
||||
return app.text('Request payload does not follow anticipated formatting.')
|
||||
}
|
||||
config := if config_.link == '' {
|
||||
SendMailConfig{
|
||||
...config_
|
||||
link: 'http://localhost:8000/email_authenticator/authentication_link'
|
||||
}
|
||||
} else {
|
||||
config_
|
||||
}
|
||||
|
||||
app.authenticator.send_verification_mail(config) or { panic(err) }
|
||||
|
||||
// checks if email verified every 2 seconds
|
||||
for {
|
||||
if app.authenticator.is_authenticated(config.email) or { panic(err) } {
|
||||
// returns success message once verified
|
||||
return app.ok('ok')
|
||||
}
|
||||
time.sleep(2 * time.second)
|
||||
}
|
||||
return app.ok('success!')
|
||||
}
|
||||
|
||||
// route responsible for verifying email, email form should be posted here
|
||||
@[POST]
|
||||
pub fn (mut app Controller) verify() vweb.Result {
|
||||
config_ := json.decode(SendMailConfig, app.req.data) or {
|
||||
app.set_status(422, 'Request payload does not follow anticipated formatting.')
|
||||
return app.text('Request payload does not follow anticipated formatting.')
|
||||
}
|
||||
|
||||
config := if config_.link == '' {
|
||||
SendMailConfig{
|
||||
...config_
|
||||
link: 'http://localhost:8000/email_authenticator/authentication_link'
|
||||
}
|
||||
} else {
|
||||
config_
|
||||
}
|
||||
|
||||
app.authenticator.send_verification_mail(config) or { panic(err) }
|
||||
|
||||
// checks if email verified every 2 seconds
|
||||
stopwatch := time.new_stopwatch()
|
||||
for stopwatch.elapsed() < 180 * time.second {
|
||||
authenticated := app.authenticator.is_authenticated(config.email) or {
|
||||
return app.text(err.msg())
|
||||
}
|
||||
if authenticated {
|
||||
console.print_debug('heyo yess')
|
||||
return app.ok('success')
|
||||
}
|
||||
time.sleep(2 * time.second)
|
||||
}
|
||||
|
||||
app.set_status(408, 'Email authentication timeout.')
|
||||
return app.text('Email authentication timeout.')
|
||||
}
|
||||
|
||||
pub struct AuthAttempt {
|
||||
pub:
|
||||
ip string
|
||||
address string
|
||||
cypher string
|
||||
}
|
||||
|
||||
@[POST]
|
||||
pub fn (mut app Controller) authenticate() !vweb.Result {
|
||||
attempt := json.decode(AuthAttempt, app.req.data)!
|
||||
app.authenticator.authenticate(attempt.address, attempt.cypher) or {
|
||||
app.set_status(401, err.msg())
|
||||
return app.text('Failed to authenticate')
|
||||
}
|
||||
return app.ok('Authentication successful')
|
||||
}
|
||||
|
||||
@['/authentication_link/:address/:cypher']
|
||||
pub fn (mut app Controller) authentication_link(address string, cypher string) !vweb.Result {
|
||||
app.authenticator.authenticate(address, cypher) or {
|
||||
app.set_status(401, err.msg())
|
||||
return app.text('Failed to authenticate')
|
||||
}
|
||||
return app.html('Authentication successful')
|
||||
}
|
||||
46
lib/security/authentication/controller_test.v
Normal file
46
lib/security/authentication/controller_test.v
Normal file
@@ -0,0 +1,46 @@
|
||||
module authentication
|
||||
|
||||
import log
|
||||
import net.smtp
|
||||
import os
|
||||
import toml
|
||||
|
||||
fn test_new_controller() {
|
||||
mut logger := log.Logger(&log.Log{
|
||||
level: .debug
|
||||
})
|
||||
|
||||
env := toml.parse_file(os.dir(os.dir(@FILE)) + '/.env') or {
|
||||
panic('Could not find .env, ${err}')
|
||||
}
|
||||
|
||||
client := smtp.Client{
|
||||
server: 'smtp-relay.brevo.com'
|
||||
from: 'verify@authenticator.io'
|
||||
port: 587
|
||||
username: env.value('BREVO_SMTP_USERNAME').string()
|
||||
password: env.value('BREVO_SMTP_PASSWORD').string()
|
||||
}
|
||||
|
||||
controller := new_controller(logger: &logger)
|
||||
}
|
||||
|
||||
fn test_send_verification_mail() {
|
||||
// mut logger := log.Logger(&log.Log{
|
||||
// level: .debug
|
||||
// })
|
||||
|
||||
// env := toml.parse_file(os.dir(os.dir(@FILE)) + '/.env') or {
|
||||
// panic('Could not find .env, ${err}')
|
||||
// }
|
||||
|
||||
// client := smtp.Client{
|
||||
// server: 'smtp-relay.brevo.com'
|
||||
// from: 'verify@authenticator.io'
|
||||
// port: 587
|
||||
// username: env.value('BREVO_SMTP_USERNAME').string()
|
||||
// password: env.value('BREVO_SMTP_PASSWORD').string()
|
||||
// }
|
||||
|
||||
// controller := new_controller(logger: &logger)
|
||||
}
|
||||
106
lib/security/authentication/email_authentication.v
Normal file
106
lib/security/authentication/email_authentication.v
Normal file
@@ -0,0 +1,106 @@
|
||||
module authentication
|
||||
|
||||
import time
|
||||
import crypto.hmac
|
||||
import crypto.sha256
|
||||
import encoding.hex
|
||||
import encoding.base64
|
||||
import freeflowuniverse.herolib.clients.mailclient {MailClient}
|
||||
|
||||
pub struct StatelessAuthenticator {
|
||||
pub:
|
||||
secret string
|
||||
pub mut:
|
||||
mail_client MailClient
|
||||
}
|
||||
|
||||
pub fn new_stateless_authenticator(authenticator StatelessAuthenticator) !StatelessAuthenticator {
|
||||
// TODO: do some checks
|
||||
return StatelessAuthenticator {...authenticator}
|
||||
}
|
||||
|
||||
pub struct AuthenticationMail {
|
||||
RedirectURLs
|
||||
pub:
|
||||
to string // email address being authentcated
|
||||
from string = 'email_authenticator@herolib.tf'
|
||||
subject string = 'Verify your email'
|
||||
body string = 'Please verify your email by clicking the link below'
|
||||
callback string // callback url of authentication link
|
||||
success_url string // where the user will be redirected upon successful authentication
|
||||
failure_url string // where the user will be redirected upon failed authentication
|
||||
}
|
||||
|
||||
pub fn (mut a StatelessAuthenticator) send_authentication_mail(mail AuthenticationMail) ! {
|
||||
link := a.new_authentication_link(mail.to, mail.callback, mail.RedirectURLs)!
|
||||
button := '<a href="${link}" style="display:inline-block; padding:10px 20px; font-size:16px; color:white; background-color:#4CAF50; text-decoration:none; border-radius:5px;">Verify Email</a>'
|
||||
|
||||
// send email with link in body
|
||||
a.mail_client.send(
|
||||
to: mail.to
|
||||
from: mail.from
|
||||
subject: mail.subject
|
||||
body_type: .html
|
||||
body: $tmpl('./templates/mail.html')
|
||||
) or { return error('Error resolving email address $err') }
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct RedirectURLs {
|
||||
pub:
|
||||
success_url string
|
||||
failure_url string
|
||||
}
|
||||
|
||||
fn (a StatelessAuthenticator) new_authentication_link(email string, callback string, urls RedirectURLs) !string {
|
||||
if urls.failure_url != '' {
|
||||
panic('implement')
|
||||
}
|
||||
|
||||
// sign email address and expiration of authentication link
|
||||
expiration := time.now().add(5 * time.minute)
|
||||
data := '${email}.${expiration}' // data to be signed
|
||||
|
||||
// QUESTION? should success url also be signed for security?
|
||||
signature := hmac.new(
|
||||
hex.decode(a.secret)!,
|
||||
data.bytes(),
|
||||
sha256.sum,
|
||||
sha256.block_size
|
||||
)
|
||||
encoded_signature := base64.url_encode(signature.bytestr().bytes())
|
||||
mut queries := ''
|
||||
if urls.success_url != '' {
|
||||
encoded_url := base64.url_encode(urls.success_url.bytes())
|
||||
queries += '?success_url=${encoded_url}'
|
||||
}
|
||||
return "${callback}/${email}/${expiration.unix()}/${encoded_signature}${queries}"
|
||||
}
|
||||
|
||||
pub struct AuthenticationAttempt {
|
||||
pub:
|
||||
email string
|
||||
expiration time.Time
|
||||
signature string
|
||||
}
|
||||
|
||||
// sends mail with login link
|
||||
pub fn (auth StatelessAuthenticator) authenticate(attempt AuthenticationAttempt) ! {
|
||||
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')
|
||||
}
|
||||
}
|
||||
49
lib/security/authentication/templates/mail.html
Normal file
49
lib/security/authentication/templates/mail.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
color: #333333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #dddddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
color: #4CAF50;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.footer {
|
||||
font-size: 12px;
|
||||
color: #888888;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">Verify Your Email</div>
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>@{mail.body}</p>
|
||||
<p>@{button}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>If you did not request this email, please ignore it.</p>
|
||||
<p>Thank you,<br>The OurWorld Team</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
175
lib/security/jwt/jwt.v
Normal file
175
lib/security/jwt/jwt.v
Normal file
@@ -0,0 +1,175 @@
|
||||
module jwt
|
||||
|
||||
import crypto.hmac
|
||||
import crypto.sha256
|
||||
import encoding.base64
|
||||
import json
|
||||
import x.json2
|
||||
import time
|
||||
import crypto.rand
|
||||
import os
|
||||
|
||||
// JWT code in this page is from
|
||||
// https://github.com/vlang/v/blob/master/examples/vweb_orm_jwt/src/auth_services.v
|
||||
// credit to https://github.com/enghitalo
|
||||
|
||||
pub struct JsonWebToken {
|
||||
JwtHeader
|
||||
JwtPayload
|
||||
}
|
||||
|
||||
struct JwtHeader {
|
||||
alg string
|
||||
typ string
|
||||
}
|
||||
|
||||
// TODO: refactor to use single JWT interface
|
||||
// todo: we can name these better
|
||||
pub struct JwtPayload {
|
||||
pub:
|
||||
sub string // (subject)
|
||||
iss string // (issuer)
|
||||
exp time.Time // (expiration)
|
||||
iat time.Time // (issued at)
|
||||
aud string // (audience)
|
||||
data string
|
||||
}
|
||||
|
||||
// creates jwt with encoded payload and header
|
||||
// DOESN'T handle data encryption, sensitive data should be encrypted
|
||||
pub fn create_token(payload_ JwtPayload) JsonWebToken {
|
||||
return JsonWebToken{
|
||||
JwtHeader: JwtHeader{'HS256', 'JWT'}
|
||||
JwtPayload: JwtPayload{
|
||||
...payload_
|
||||
iat: time.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_secret() string {
|
||||
bytes := rand.bytes(64) or { panic('Creating JWT Secret: ${err}') }
|
||||
return bytes.bytestr()
|
||||
}
|
||||
|
||||
pub fn (token JsonWebToken) sign(secret string) string {
|
||||
header := base64.url_encode(json.encode(token.JwtHeader).bytes())
|
||||
payload := base64.url_encode(json.encode(token.JwtPayload).bytes())
|
||||
signature := base64.url_encode(hmac.new(secret.bytes(), '${header}.${payload}'.bytes(),
|
||||
sha256.sum, sha256.block_size).bytestr().bytes())
|
||||
return '${header}.${payload}.${signature}'
|
||||
}
|
||||
|
||||
pub fn (token JsonWebToken) is_expired() bool {
|
||||
return token.exp <= time.now()
|
||||
}
|
||||
|
||||
pub type SignedJWT = string
|
||||
|
||||
pub fn (token SignedJWT) is_valid() bool {
|
||||
return token.count('.') == 2
|
||||
}
|
||||
|
||||
pub fn (token SignedJWT) verify(secret string) !bool {
|
||||
if !token.is_valid() {
|
||||
return error('Token `${token}` is not valid')
|
||||
}
|
||||
signature_mirror := hmac.new(secret.bytes(), token.all_before_last('.').bytes(), sha256.sum,
|
||||
sha256.block_size).bytestr().bytes()
|
||||
signature_token := base64.url_decode(token.all_after_last('.'))
|
||||
return hmac.equal(signature_token, signature_mirror)
|
||||
}
|
||||
|
||||
// gets cookie token, returns user obj
|
||||
pub fn (token SignedJWT) decode() !JsonWebToken {
|
||||
if !token.is_valid() {
|
||||
return error('Token `${token}` is not valid')
|
||||
}
|
||||
header_urlencoded := token.split('.')[0]
|
||||
header_json := base64.url_decode(header_urlencoded).bytestr()
|
||||
header := json.decode(JwtHeader, header_json) or { panic('Decode header: ${err}') }
|
||||
payload_urlencoded := token.split('.')[1]
|
||||
payload_json := base64.url_decode(payload_urlencoded).bytestr()
|
||||
payload := json.decode(JwtPayload, payload_json) or { panic('Decoding payload: ${err}') }
|
||||
return JsonWebToken{
|
||||
JwtHeader: header
|
||||
JwtPayload: payload
|
||||
}
|
||||
}
|
||||
|
||||
// gets cookie token, returns user obj
|
||||
pub fn (token SignedJWT) get_field(field string) !string {
|
||||
if !token.is_valid() {
|
||||
return error('Token `${token}` is not valid')
|
||||
}
|
||||
header_urlencoded := token.split('.')[0]
|
||||
header_json := base64.url_decode(header_urlencoded).bytestr()
|
||||
header := json.decode(JwtHeader, header_json) or { panic('Decode header: ${err}') }
|
||||
payload_urlencoded := token.split('.')[1]
|
||||
payload_json := base64.url_decode(payload_urlencoded).bytestr()
|
||||
payload_raw := json2.raw_decode(payload_json) or { panic('Decoding payload: ${err}') }
|
||||
payload_map := payload_raw.as_map()
|
||||
return payload_map[field].str()
|
||||
}
|
||||
|
||||
// gets cookie token, returns user obj
|
||||
pub fn (token SignedJWT) decode_subject() !string {
|
||||
decoded := token.decode()!
|
||||
return decoded.sub
|
||||
}
|
||||
|
||||
// verifies jwt cookie
|
||||
pub fn verify_jwt(token string) bool {
|
||||
if token == '' {
|
||||
return false
|
||||
}
|
||||
secret := os.getenv('SECRET_KEY')
|
||||
token_split := token.split('.')
|
||||
|
||||
signature_mirror := hmac.new(secret.bytes(), '${token_split[0]}.${token_split[1]}'.bytes(),
|
||||
sha256.sum, sha256.block_size).bytestr().bytes()
|
||||
|
||||
signature_from_token := base64.url_decode(token_split[2])
|
||||
|
||||
return hmac.equal(signature_from_token, signature_mirror)
|
||||
}
|
||||
|
||||
// verifies jwt cookie
|
||||
// todo: implement assymetric verification
|
||||
pub fn verify_jwt_assymetric(token string, pk string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// gets cookie token, returns user obj
|
||||
pub fn get_data(token string) !string {
|
||||
if token == '' {
|
||||
return error('Failed to decode token: token is empty')
|
||||
}
|
||||
payload := json.decode(JwtPayload, base64.url_decode(token.split('.')[1]).bytestr()) or {
|
||||
panic(err)
|
||||
}
|
||||
return payload.data
|
||||
}
|
||||
|
||||
// gets cookie token, returns user obj
|
||||
pub fn get_payload(token string) !JwtPayload {
|
||||
if token == '' {
|
||||
return error('Failed to decode token: token is empty')
|
||||
}
|
||||
encoded_payload := base64.url_decode(token.split('.')[1]).bytestr()
|
||||
return json.decode(JwtPayload, encoded_payload)!
|
||||
}
|
||||
|
||||
// // gets cookie token, returns access obj
|
||||
// pub fn get_access(token string, username string) ?Access {
|
||||
// if token == '' {
|
||||
// return error('Cookie token is empty')
|
||||
// }
|
||||
// payload := json.decode(AccessPayload, base64.url_decode(token.split('.')[1]).bytestr()) or {
|
||||
// panic(err)
|
||||
// }
|
||||
// if payload.user != username {
|
||||
// return error('Access cookie is for different user')
|
||||
// }
|
||||
// return payload.access
|
||||
// }
|
||||
Reference in New Issue
Block a user