Implement basic login & registration forms
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import {ErrorWithContext} from '../util'
|
||||
|
||||
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
|
||||
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import {Inject, Injectable} from '../di'
|
||||
import {EventBus} from '../event/EventBus'
|
||||
import {Awaitable, Maybe} from '../util'
|
||||
import {Authenticatable, AuthenticatableRepository} from './types'
|
||||
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
|
||||
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
|
||||
import {UserFlushedEvent} from './event/UserFlushedEvent'
|
||||
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
|
||||
import {Logging} from '../service/Logging'
|
||||
|
||||
/**
|
||||
* Base-class for a context that authenticates users and manages security.
|
||||
@@ -14,6 +15,9 @@ export abstract class SecurityContext {
|
||||
@Inject()
|
||||
protected readonly bus!: EventBus
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** The currently authenticated user, if one exists. */
|
||||
private authenticatedUser?: Authenticatable
|
||||
|
||||
@@ -57,7 +61,7 @@ export abstract class SecurityContext {
|
||||
* unauthenticated implicitly.
|
||||
* @param credentials
|
||||
*/
|
||||
async attemptOnce(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
||||
async attemptOnce(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
await this.authenticateOnce(user)
|
||||
@@ -71,7 +75,7 @@ export abstract class SecurityContext {
|
||||
* authentication will be persisted.
|
||||
* @param credentials
|
||||
*/
|
||||
async attempt(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
||||
async attempt(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
await this.authenticate(user)
|
||||
@@ -108,6 +112,8 @@ export abstract class SecurityContext {
|
||||
*/
|
||||
async resume(): Promise<void> {
|
||||
const credentials = await this.getCredentials()
|
||||
this.logging.debug('resume:')
|
||||
this.logging.debug(credentials)
|
||||
const user = await this.repository.getByCredentials(credentials)
|
||||
if ( user ) {
|
||||
this.authenticatedUser = user
|
||||
@@ -125,7 +131,7 @@ export abstract class SecurityContext {
|
||||
* Get the credentials for the current user from whatever storage medium
|
||||
* the context's host provides.
|
||||
*/
|
||||
abstract getCredentials(): Awaitable<Record<string, string>>
|
||||
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
|
||||
|
||||
/**
|
||||
* Get the currently authenticated user, if one exists.
|
||||
@@ -138,6 +144,8 @@ export abstract class SecurityContext {
|
||||
* Returns true if there is a currently authenticated user.
|
||||
*/
|
||||
hasUser(): boolean {
|
||||
this.logging.debug('hasUser?')
|
||||
this.logging.debug(this.authenticatedUser)
|
||||
return Boolean(this.authenticatedUser)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,145 @@
|
||||
import {Controller} from '../../http/Controller'
|
||||
import {Injectable} from '../../di'
|
||||
import {Route} from '../../http/routing/Route'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {ResponseObject, Route} from '../../http/routing/Route'
|
||||
import {Request} from '../../http/lifecycle/Request'
|
||||
import {view} from '../../http/response/ViewResponseFactory'
|
||||
import {ResponseFactory} from '../../http/response/ResponseFactory'
|
||||
import {SecurityContext} from '../SecurityContext'
|
||||
import {BasicLoginFormRequest} from './BasicLoginFormRequest'
|
||||
import {Routing} from '../../service/Routing'
|
||||
import {Valid, ValidationError} from '../../forms'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
import {BasicRegisterFormRequest} from './BasicRegisterFormRequest'
|
||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {temporary} from '../../http/response/TemporaryRedirectResponseFactory'
|
||||
|
||||
@Injectable()
|
||||
export class BasicLoginController extends Controller {
|
||||
public static routes(): void {
|
||||
public static routes({ enableRegistration = true } = {}): void {
|
||||
Route.group('auth', () => {
|
||||
Route.get('login', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getLogin()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.login')
|
||||
|
||||
Route.post('login', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getLogin()
|
||||
return controller.attemptLogin()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.login.attempt')
|
||||
|
||||
Route.any('logout', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptLogout()
|
||||
})
|
||||
.pre('@auth:required')
|
||||
.alias('@auth.logout')
|
||||
|
||||
if ( enableRegistration ) {
|
||||
Route.get('register', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.getRegistration()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.register')
|
||||
|
||||
Route.post('register', (request: Request) => {
|
||||
const controller = <BasicLoginController> request.make(BasicLoginController)
|
||||
return controller.attemptRegister()
|
||||
})
|
||||
.pre('@auth:guest')
|
||||
.alias('@auth.register.attempt')
|
||||
}
|
||||
}).pre('@auth:web')
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly security!: SecurityContext
|
||||
|
||||
@Inject()
|
||||
protected readonly routing!: Routing
|
||||
|
||||
@Inject()
|
||||
protected readonly session!: Session
|
||||
|
||||
public getLogin(): ResponseFactory {
|
||||
return this.getLoginView()
|
||||
}
|
||||
|
||||
public getRegistration(): ResponseFactory {
|
||||
return this.getRegistrationView()
|
||||
}
|
||||
|
||||
public async attemptLogin(): Promise<ResponseObject> {
|
||||
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
|
||||
|
||||
try {
|
||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||
const user = await this.security.attempt(data)
|
||||
if ( user ) {
|
||||
const intention = this.session.get('auth.intention', '/')
|
||||
this.session.forget('auth.intention')
|
||||
return temporary(intention)
|
||||
}
|
||||
|
||||
return this.getLoginView(['Invalid username/password.'])
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof ValidationError ) {
|
||||
return this.getLoginView(e.errors.all())
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
public async attemptLogout(): Promise<ResponseObject> {
|
||||
await this.security.flush()
|
||||
return this.getMessageView('You have been logged out.')
|
||||
}
|
||||
|
||||
public async attemptRegister(): Promise<ResponseObject> {
|
||||
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
|
||||
|
||||
try {
|
||||
const data: Valid<AuthenticatableCredentials> = await form.get()
|
||||
const user = await this.security.repository.createByCredentials(data)
|
||||
await this.security.authenticate(user)
|
||||
|
||||
const intention = this.session.get('auth.intention', '/')
|
||||
this.session.forget('auth.intention')
|
||||
return temporary(intention)
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof ValidationError ) {
|
||||
return this.getRegistrationView(e.errors.all())
|
||||
} else if ( e instanceof AuthenticatableAlreadyExistsError ) {
|
||||
return this.getRegistrationView(['A user with that username already exists.'])
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
protected getLoginView(errors?: string[]): ResponseFactory {
|
||||
return view('@extollo:auth:login', {
|
||||
formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
public getLogin(): ResponseFactory {
|
||||
return view('@extollo:auth:login')
|
||||
protected getRegistrationView(errors?: string[]): ResponseFactory {
|
||||
return view('@extollo:auth:register', {
|
||||
formAction: this.routing.getNamedPath('@auth.register.attempt').toRemote,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
|
||||
protected getMessageView(message: string): ResponseFactory {
|
||||
return view('@extollo:auth:message', {
|
||||
message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
|
||||
export interface BasicLoginCredentials {
|
||||
username: string,
|
||||
password: string,
|
||||
}
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
|
||||
@Singleton()
|
||||
export class BasicLoginFormRequest extends FormRequest<BasicLoginCredentials> {
|
||||
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
username: [
|
||||
identifier: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
password: [
|
||||
credential: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
],
|
||||
|
||||
22
src/auth/basic-ui/BasicRegisterFormRequest.ts
Normal file
22
src/auth/basic-ui/BasicRegisterFormRequest.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {FormRequest, ValidationRules} from '../../forms'
|
||||
import {Is, Str} from '../../forms/rules/rules'
|
||||
import {Singleton} from '../../di'
|
||||
import {AuthenticatableCredentials} from '../types'
|
||||
|
||||
@Singleton()
|
||||
export class BasicRegisterFormRequest extends FormRequest<AuthenticatableCredentials> {
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
identifier: [
|
||||
Is.required,
|
||||
Str.lengthMin(1),
|
||||
Str.alphaNum,
|
||||
],
|
||||
credential: [
|
||||
Is.required,
|
||||
Str.lengthMin(8),
|
||||
Str.confirmed,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import {SecurityContext} from '../SecurityContext'
|
||||
import {Inject, Injectable} from '../../di'
|
||||
import {Session} from '../../http/session/Session'
|
||||
import {Awaitable} from '../../util'
|
||||
import {AuthenticatableRepository} from '../types'
|
||||
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
|
||||
|
||||
/**
|
||||
* Security context implementation that uses the session as storage.
|
||||
@@ -19,9 +19,10 @@ export class SessionSecurityContext extends SecurityContext {
|
||||
super(repository, 'session')
|
||||
}
|
||||
|
||||
getCredentials(): Awaitable<Record<string, string>> {
|
||||
getCredentials(): Awaitable<AuthenticatableCredentials> {
|
||||
return {
|
||||
securityIdentifier: this.session.get('extollo.auth.securityIdentifier'),
|
||||
identifier: '',
|
||||
credential: this.session.get('extollo.auth.securityIdentifier'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,11 +24,11 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||
|
||||
/** The user's first name. */
|
||||
@Field(FieldType.varchar, 'first_name')
|
||||
public firstName!: string
|
||||
public firstName?: string
|
||||
|
||||
/** The user's last name. */
|
||||
@Field(FieldType.varchar, 'last_name')
|
||||
public lastName!: string
|
||||
public lastName?: string
|
||||
|
||||
/** The hashed and salted password of the user. */
|
||||
@Field(FieldType.varchar, 'password_hash')
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import {Authenticatable, AuthenticatableIdentifier, AuthenticatableRepository} from '../types'
|
||||
import {
|
||||
Authenticatable,
|
||||
AuthenticatableCredentials,
|
||||
AuthenticatableIdentifier,
|
||||
AuthenticatableRepository,
|
||||
} from '../types'
|
||||
import {Awaitable, Maybe} from '../../util'
|
||||
import {ORMUser} from './ORMUser'
|
||||
import {Injectable} from '../../di'
|
||||
import {Container, Inject, Injectable} from '../../di'
|
||||
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
|
||||
|
||||
/**
|
||||
* A user repository implementation that looks up users stored in the database.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ORMUserRepository extends AuthenticatableRepository {
|
||||
@Inject('injector')
|
||||
protected readonly injector!: Container
|
||||
|
||||
/** Look up the user by their username. */
|
||||
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
|
||||
return ORMUser.query<ORMUser>()
|
||||
@@ -21,21 +30,36 @@ export class ORMUserRepository extends AuthenticatableRepository {
|
||||
* If username/password are specified, look up the user and verify the password.
|
||||
* @param credentials
|
||||
*/
|
||||
async getByCredentials(credentials: Record<string, string>): Promise<Maybe<Authenticatable>> {
|
||||
if ( credentials.securityIdentifier ) {
|
||||
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
|
||||
if ( !credentials.identifier && credentials.credential ) {
|
||||
return ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.securityIdentifier)
|
||||
.where('username', '=', credentials.credential)
|
||||
.first()
|
||||
}
|
||||
|
||||
if ( credentials.username && credentials.password ) {
|
||||
if ( credentials.identifier && credentials.credential ) {
|
||||
const user = await ORMUser.query<ORMUser>()
|
||||
.where('username', '=', credentials.username)
|
||||
.where('username', '=', credentials.identifier)
|
||||
.first()
|
||||
|
||||
if ( user && await user.verifyPassword(credentials.password) ) {
|
||||
if ( user && await user.verifyPassword(credentials.credential) ) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async createByCredentials(credentials: AuthenticatableCredentials): Promise<Authenticatable> {
|
||||
if ( await this.getByCredentials(credentials) ) {
|
||||
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
|
||||
identifier: credentials.identifier,
|
||||
})
|
||||
}
|
||||
|
||||
const user = <ORMUser> this.injector.make(ORMUser)
|
||||
user.username = credentials.identifier
|
||||
await user.setPassword(credentials.credential)
|
||||
await user.save()
|
||||
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
|
||||
/** Value that can be used to uniquely identify a user. */
|
||||
export type AuthenticatableIdentifier = string | number
|
||||
|
||||
export interface AuthenticatableCredentials {
|
||||
identifier: string,
|
||||
credential: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for entities that can be authenticated.
|
||||
*/
|
||||
@@ -32,5 +37,7 @@ export abstract class AuthenticatableRepository {
|
||||
* Returns the user if the credentials are valid.
|
||||
* @param credentials
|
||||
*/
|
||||
abstract getByCredentials(credentials: Record<string, string>): Awaitable<Maybe<Authenticatable>>
|
||||
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
|
||||
|
||||
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user