@webda/core
Version:
Expose API with Lambda
772 lines • 27.1 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import bcrypt from "bcryptjs";
import { Counter } from "../core.js";
import { WebdaError } from "../errors.js";
import { Inject, Route, Service, ServiceParameters } from "../services/service.js";
import { HttpContext } from "../utils/httpcontext.js";
export class AuthenticationParameters extends ServiceParameters {
constructor(params) {
var _a, _b;
super(params);
this.identModel ?? (this.identModel = "Webda/Ident");
this.userModel ?? (this.userModel = "Webda/User");
this.url ?? (this.url = "/auth");
this.password ?? (this.password = {
regexp: ".{8,}"
});
if (this.email) {
(_a = this.email).delay ?? (_a.delay = 3600000 * 4);
(_b = this.email).failedLoginBeforeDelay ?? (_b.failedLoginBeforeDelay = 3);
}
}
}
/**
* This class is known as the Authentication module
* It handles OAuth for several providers for now (Facebook, Google, Amazon, GitHub and Twitter)
* It also handles email authentication with prevalidation or postvalidation of the email
*
* It requires two Store to work one `idents` and one `users`
*
* The parameters are
* ```
* providerName: {
* clientID: '...',
* clientSecret: '...',
* scope: ''
* },
* email: {
* postValidation: true|false // If postValidation=true, account created without email verification
* }
* url: 'url' // By default /auth
* ```
*
* @category CoreServices
* @WebdaModda
*/
class Authentication extends Service {
constructor() {
super(...arguments);
this.providers = new Set();
}
/**
* Load the parameters for a service
*/
loadParameters(params) {
return new AuthenticationParameters(params);
}
/**
* Get the user store
* @returns
*/
getUserStore() {
return this._userModel.store();
}
/**
* Get the user store
* @returns
*/
getIdentStore() {
return this._identModel.store();
}
/**
* @ignore
* Setup the default routes
*/
computeParameters() {
super.computeParameters();
this._identModel = this.getWebda().getModel(this.parameters.identModel);
this._userModel = this.getWebda().getModel(this.parameters.userModel);
if (this.parameters.password.verifier) {
this._passwordVerifier = this.getService(this.parameters.password.verifier);
}
if (this.parameters.email && this.getMailMan() === undefined) {
throw Error("email authentication requires a Mailer service");
}
// Add email provider
if (this.parameters.email) {
this.addProvider("email");
this.addRoute("./email/{email}/recover", ["GET"], this._passwordRecoveryEmail, {
get: {
description: "The password reset process will be start",
summary: "Start password recovery",
operationId: "startEmailRecovery",
responses: {
"204": {
description: ""
},
"404": {
description: "Email does not exist"
},
"429": {
description: "Recovery has been initiated in the last 4 hours"
}
}
}
});
this.addRoute("./email/passwordRecovery", ["POST"], this._passwordRecovery, {
post: {
schemas: {
input: {
type: "object",
properties: {
token: {
type: "string"
},
expire: {
type: "number"
},
password: {
type: "string"
},
login: {
type: "string"
}
}
}
},
description: "Reinit the password if we have the right token, expire",
summary: "Reinit password",
operationId: "reinitPassword",
responses: {
"204": {
description: ""
},
"403": {
description: "Wrong Token"
}
}
}
});
this.addRoute("./email", ["POST"], this._handleEmail, {
post: {
description: "Authenticate with an email and password",
summary: "Authenticate with email",
operationId: `authWithEmail`
}
});
}
}
/**
* @override
*/
initMetrics() {
super.initMetrics();
this.metrics.login = this.getMetric(Counter, {
name: "auth_login",
help: "Counter number of login per provider",
labelNames: ["provider"]
});
this.metrics.logout = this.getMetric(Counter, {
name: "auth_logout",
help: "Counter number of logout"
});
this.metrics.registration = this.getMetric(Counter, {
name: "auth_registration",
help: "Counter number of registration per provider",
labelNames: ["provider"]
});
if (this.parameters.email) {
this.metrics.loginFailed = this.getMetric(Counter, {
name: "auth_login_failed",
help: "Counter number of login failed with password"
});
this.metrics.recovery = this.getMetric(Counter, {
name: "auth_login_recovery",
help: "Counter number of password lost process initiated"
});
this.metrics.recovered = this.getMetric(Counter, {
name: "auth_login_recovered",
help: "Counter number of password lost process successful"
});
}
}
/**
* Add a provider to the oauth scheme
* @param name
*/
addProvider(name) {
this.providers.add(name);
}
/**
* Ensure email is enabled for all emails routes
* @override
*/
getUrl(url, methods) {
if (url.startsWith("./emails") && !this.parameters.email) {
return undefined;
}
return super.getUrl(url, methods);
}
/**
* Send or resend an email to validate the email address
*
* @param ctx
* @throws 409 if ident is linked to someone else
* @throws 412 if the email is already validated
* @throws 429 if a validation email has been sent recently
*/
async _sendEmailValidation(ctx) {
let identKey = ctx.parameters.email + "_email";
let ident = await this._identModel.ref(identKey).get();
if (!ident) {
await this._identModel.ref(`${ctx.parameters.email}_email`).create({
_lastValidationEmail: Date.now(),
_type: "email"
});
}
else {
// If the ident is linked to someone else - might want to remove it
if (ident.getUser() !== ctx.getCurrentUserId()) {
throw new WebdaError.Conflict("Ident is linked to someone else");
}
// If the email is already validated
if (ident._validation) {
throw new WebdaError.PreconditionFailed("Email already validated");
}
//
if (ident._lastValidationEmail >= Date.now() - this.parameters.email.delay) {
throw new WebdaError.TooManyRequests("Email sent recently");
}
await this._identModel.ref(ident.getUuid()).setAttribute("_lastValidationEmail", Date.now());
}
await this.sendValidationEmail(ctx, ctx.parameters.email);
}
/**
* Return current user
* @param ctx
*/
async _getMe(ctx) {
let user = await ctx.getCurrentUser();
if (user === undefined) {
throw new WebdaError.NotFound("No user found");
}
await this.emitSync("Authentication.GetMe", {
context: ctx,
user
});
ctx.write(user);
}
/**
* Handle both list of available authentication
* and logout with method 'DELETE'
*
* @param ctx
* @returns
*/
async _listAuthentications(ctx) {
if (ctx.getHttpContext().getMethod() === "DELETE") {
await this.logout(ctx);
this.metrics.logout.inc();
ctx.write("GoodBye");
return;
}
ctx.write(Array.from(this.providers));
}
async onIdentLogin(ctx, provider, identId, profile, tokens = undefined) {
// Auto postifx with provider name
const postfix = `_${provider}`;
if (!identId.endsWith(postfix)) {
identId += postfix;
}
let ident = await this._identModel.ref(identId).get();
// Ident is known
if (ident) {
await this.login(ctx, ident.getUser().toString(), ident, provider);
await ident.patch({
_lastUsed: new Date(),
__tokens: tokens
});
// Redirect to?
return;
}
let user;
if (profile.email) {
ident = await this._identModel.ref(`${profile.email}_email`).get();
}
// If already login
if (ctx.getCurrentUserId()) {
user = await ctx.getCurrentUser();
}
else {
// If an email in profile try to find the ident
if (ident) {
user = await this._userModel.ref(ident.getUser().toString()).get();
}
if (!user) {
// If no user, register a new user automatically
user = await this.registerUser(ctx, profile, identId);
await user.save();
}
}
if (profile.email && !ident) {
// Save additional email
await new this._identModel()
.load({
uuid: `${profile.email}_email`,
provider: "email",
_user: user.getUuid()
}, true)
.save();
}
// Work directly on ident argument
ident = new this._identModel().load({
uuid: identId,
__profile: profile,
__tokens: tokens,
provider
}, true);
ident.setUser(user.uuid);
ident._lastUsed = new Date();
ident.setType(provider);
await ident.save();
ident.__new = true;
await this.login(ctx, user, ident, provider);
}
/**
* Create a new User with the link ident
* @param ident
*/
async createUserWithIdent(provider, identId, profile = {}) {
if (await this._identModel.ref(`${identId}_${provider}`).exists()) {
throw new Error("Ident is already known");
}
let ctx = await this._webda.newWebContext(new HttpContext("fake", "GET", "/"));
// Pretend we logged in with the ident
await this.onIdentLogin(ctx, provider, identId, profile);
}
async registerUser(ctx, data, identId, user = new this._userModel()) {
user.load({
email: data.email,
locale: ctx.getLocale()
}, true);
this.metrics.registration.inc();
await this.emitSync("Authentication.Register", {
user: user,
data: data,
context: ctx,
identId
});
return user;
}
async getPasswordRecoveryInfos(uuid, interval = this.parameters.email.delay) {
const expire = Date.now() + interval;
let user;
if (typeof uuid === "string") {
user = await this._userModel.ref(uuid).get();
}
else {
user = uuid;
}
if (!user) {
return undefined;
}
return {
expire: expire,
// Might want to add more alea not coming from the db to avoid exploitation of stolen db
token: await this.cryptoService.hmac(user.uuid + expire + user.getPassword()),
login: user.uuid
};
}
/**
* Manage password recovery
* @param ctx
*/
async _passwordRecoveryEmail(ctx) {
let email = ctx.parameter("email");
let ident = await this._identModel.ref(email + "_email").get();
if (!ident) {
throw new WebdaError.NotFound("Email not found");
}
let user = await this._userModel.ref(ident.getUser().toString()).get();
// Dont allow to do too many request
if (!user.lastPasswordRecoveryBefore(Date.now() - this.parameters.email.delay)) {
throw new WebdaError.TooManyRequests("Password recovery already requested recently");
}
await user.patch({
_lastPasswordRecovery: Date.now()
}, null);
this.metrics.recovery.inc();
await this.sendRecoveryEmail(ctx, user, email);
}
async _verifyPassword(password, user) {
if (this._passwordVerifier) {
if (!(await this._passwordVerifier.validate(password, user))) {
throw new WebdaError.BadRequest("Password does not match the policy");
}
return;
}
let regexp = new RegExp(this.parameters.password.regexp);
if (regexp.exec(password) === null) {
throw new WebdaError.BadRequest("Password does not match the policy");
}
}
async _passwordRecovery(ctx) {
let body = await ctx.getRequestBody();
let user = await this._userModel.ref(body.login.toLowerCase()).get();
if (!user) {
throw new WebdaError.Forbidden("User not found");
}
if (!(await this.cryptoService.hmacVerify(body.login.toLowerCase() + body.expire + user.getPassword(), body.token))) {
throw new WebdaError.Forbidden("Invalid token");
}
if (body.expire < Date.now()) {
throw new WebdaError.Gone("Expired token");
}
await this._verifyPassword(body.password, user);
let password = this.hashPassword(body.password);
await user.patch({
__password: password
}, null);
this.metrics.recovered.inc();
await this.emitSync("Authentication.PasswordUpdate", {
user,
password,
context: ctx
});
}
/**
* Callback to validate an email address
* @param ctx
* @returns
*/
async _handleEmailCallback(ctx) {
if (!(await this.cryptoService.hmacVerify(`${ctx.parameter("email")}_${ctx.parameter("user")}`, ctx.parameter("token")))) {
ctx.writeHead(302, {
Location: this.parameters.failureRedirect + "?reason=badToken"
});
return;
}
if (ctx.parameter("user") !== ctx.getCurrentUserId() &&
ctx.getCurrentUserId() !== "" &&
ctx.getCurrentUserId() !== undefined) {
ctx.writeHead(302, {
Location: this.parameters.failureRedirect + "?reason=badUser"
});
return;
}
if (!ctx.parameter("user")) {
ctx.writeHead(302, {
Location: `${this.parameters.registerRedirect}?token=${ctx.parameter("token")}&email=${ctx.parameter("email")}`
});
return;
}
const uuid = ctx.parameter("email") + "_email";
let ident = await this._identModel.ref(uuid).get();
// Would mean the ident got delete in the mean time... hyper low likely hood
if (ident === undefined) {
ident = new this._identModel().setUuid(uuid);
}
ident._type = "email";
ident._validation = new Date();
ident.setUser(ctx.parameter("user"));
await ident.save();
ctx.writeHead(302, {
Location: this.parameters.successRedirect + "?validation=email",
"X-Webda-Authentication": "success"
});
}
/**
* Send an email to recover the user password
*
* @param ctx
* @param user
* @param email
* @returns
*/
async sendRecoveryEmail(ctx, user, email) {
let infos = await this.getPasswordRecoveryInfos(user);
const mailer = this.getMailMan();
let locale = user.locale || ctx.getLocale();
let mailOptions = {
to: email,
locale: locale,
template: "EMAIL_RECOVERY",
replacements: {
...this.parameters.email,
infos,
to: email,
context: ctx
}
};
return mailer.send(mailOptions);
}
/**
* Send an email to validate the user email by sending a unique link to
* his email
*
* @param ctx
* @param email
* @returns
*/
async sendValidationEmail(ctx, email) {
const mailer = this.getMailMan();
let replacements = {
...this.parameters.email,
context: ctx,
url: ctx
.getHttpContext()
.getAbsoluteUrl(`${this.parameters.url}/email/callback?email=${email}&token=${await this.generateEmailValidationToken(ctx.getCurrentUserId(), email)}`)
};
let userId = ctx.getCurrentUserId();
if (userId && userId.length > 0) {
replacements.url += "&user=" + userId;
}
let mailOptions = {
to: email,
locale: ctx.getLocale(),
template: "EMAIL_REGISTER",
replacements: replacements
};
return mailer.send(mailOptions);
}
/**
* Check the password match the stored hash
* @param hash generate prior by hashPassword
* @param password as entered by the user
*/
checkPassword(hash, pass) {
return bcrypt.compareSync(pass, hash);
}
/**
* Hash the password according to good practices
*
* @param pass to hash
*/
hashPassword(pass) {
return bcrypt.hashSync(pass, this.parameters.salt || 10);
}
/**
* Logout user
*/
async logout(ctx) {
await this.emitSync("Authentication.Logout", {
context: ctx
});
await ctx.newSession();
}
/**
* Login a user
*
* @param ctx
* @param user
* @param ident
* @returns
*/
async login(ctx, user, ident, provider) {
const event = {
context: ctx,
userId: "",
identId: ident.uuid,
ident
};
if (typeof user == "object") {
event.userId = user.getUuid();
event.user = user;
}
else {
event.userId = user;
}
event.context = ctx;
ctx.getSession().login(event.userId, event.identId);
this.metrics.login.inc({ provider });
return this.emitSync("Authentication.Login", event);
}
getMailMan() {
return this.getService(this.parameters.email.mailer ? this.parameters.email.mailer : "Mailer");
}
/**
* Handle a user login request
*
* @param ctx
* @param ident
*/
async handleLogin(ctx, ident) {
let updates = {};
let user = await this._userModel.ref(ident.getUser().toString()).get();
// Check password
if (this.checkPassword(user.getPassword(), (await ctx.getRequestBody()).password)) {
if (ident._failedLogin > 0) {
ident._failedLogin = 0;
}
updates._lastUsed = new Date();
updates._failedLogin = 0;
await this._identModel.ref(ident.uuid).patch(updates);
await this.login(ctx, ident.getUser().toString(), ident, "email");
ctx.write(user);
}
else {
this.metrics.loginFailed.inc();
await this.emitSync("Authentication.LoginFailed", {
user,
context: ctx
});
ident._failedLogin ?? (ident._failedLogin = 0);
updates._failedLogin = ident._failedLogin + 1;
updates._lastFailedLogin = new Date();
// Swalow exeception issue to double check !
await this._identModel.ref(ident.uuid).patch(updates);
// Allows to auto redirect user to a oauth if needed
// It can intercept the error and redirect to the oauth if
// email match
if (!ctx.isEnded()) {
throw new WebdaError.Forbidden("Invalid password");
}
}
}
/**
* Handle the POST /auth/email
*
* @param ctx
* @returns
*/
async _handleEmail(ctx) {
// If called while logged in reject
if (ctx.getCurrentUserId() !== undefined) {
throw new WebdaError.Gone("Already logged in");
}
let body = await ctx.getRequestBody();
const mailConfig = this.parameters.email;
const uuid = body.login.toLowerCase() + "_email";
let ident = await this._identModel.ref(uuid).get();
if (ident !== undefined && ident.getUser() !== undefined) {
// Register on an known user
if (!body.register) {
await this.handleLogin(ctx, ident);
return;
}
// If register on a validate email
if (ident._validation !== undefined) {
throw new WebdaError.Conflict("Email already registered");
}
}
// TODO Handle add of email on authenticated user
const email = body.login.toLowerCase();
// Read the form
if (body.register) {
let validation = undefined;
// Need to check email before creation
if (!mailConfig.postValidation) {
if (body.token && (await this.cryptoService.hmacVerify(`${email}_${ctx.getCurrentUserId()}`, body.token))) {
validation = new Date();
}
else {
ctx.write({});
// token is undefined send an email
return this.sendValidationEmail(ctx, email);
}
}
if (!body.password) {
throw new WebdaError.BadRequest("Password is required");
}
// Store with a _
let __password = this.hashPassword(body.password);
await this._verifyPassword(body.password);
// Remove useless attributes
delete body.password;
delete body.register;
delete body.token;
let user = await this.registerUser(ctx, {}, uuid, new this._userModel().load({
...body,
__password
}, true));
await this.emitSync("Authentication.PasswordCreate", {
user,
password: __password,
context: ctx
});
await user.save();
const newIdent = new this._identModel().load({
uuid: uuid,
_type: "email",
email: email
}, true);
newIdent.setUser(user.getUuid());
if (validation) {
newIdent._validation = validation;
}
else if (!mailConfig.skipEmailValidation) {
newIdent._lastValidationEmail = Date.now();
}
ident = await newIdent.save();
await this.login(ctx, user, ident, "email");
ctx.write(user);
if (!validation && !mailConfig.skipEmailValidation) {
await this.sendValidationEmail(ctx, email);
}
return;
}
throw new WebdaError.NotFound("");
}
async generateEmailValidationToken(user, email) {
return this.cryptoService.hmac(email + "_" + user);
}
}
__decorate([
Inject("CryptoService")
], Authentication.prototype, "cryptoService", void 0);
__decorate([
Route("./email/{email}/validate", ["GET"], {
get: {
description: "The email validation process will be start",
summary: "Restart email validation",
operationId: "startEmailRecovery",
responses: {
"204": {
description: ""
},
"409": {
description: "Email already verified for another user"
},
"412": {
description: "Email already verified for current user"
},
"429": {
description: "Validation has been initiated in the last 4 hours"
}
}
}
})
], Authentication.prototype, "_sendEmailValidation", null);
__decorate([
Route("./me", ["GET"], {
get: {
description: "Retrieve the current user from the session",
summary: "Get current user",
operationId: "getCurrentUser"
}
})
], Authentication.prototype, "_getMe", null);
__decorate([
Route(".", ["GET", "DELETE"], {
get: {
description: "Retrieve the list of available authentication",
summary: "Get available auths",
operationId: `getAuthenticationMethods`,
responses: {
"200": {
description: "List of authentication"
}
}
},
delete: {
description: "Logout current user",
summary: "Logout",
operationId: `logout`,
responses: {
"200": {}
}
}
})
], Authentication.prototype, "_listAuthentications", null);
__decorate([
Route("./email/callback{?email,token,user?}", ["GET"], {
hidden: true
})
], Authentication.prototype, "_handleEmailCallback", null);
export { Authentication };
//# sourceMappingURL=authentication.js.map