import { Service } from "root-ts" import { MongodbService } from "root-ts" import { TTLSessionStorage } from "root-ts" import * as Leaf from "leaf-ts" import * as crypto from "crypto" const Errors = Leaf.ErrorDoc.build({ InvalidParameter: {}, NotFound: {}, AlreadyExists: {} }) export type AccountDefinition = Type.Account.AccountService.AccountDefinition export type AuthenticationData = Type.Account.AuthenticationService.AuthenticationData export class AuthenticationService extends Service { readonly name = "AuthenticationService" protected collectionName = "authentication" readonly dependencies = ["MongodbService", "TTLSessionStorage"] constructor(settings: Type.Account.AuthenticationService.Settings) { super({ authentication: { ttl: 1000 * 60 * 60 * 24 * 30 } }, settings) this.define(new CommonAuthenticationDefinition) } services: { MongodbService: MongodbService TTLSessionStorage: TTLSessionStorage } authenticationCollection: any createToken(option: { account: TAccount ttl?: number }, callback: Callback) { this.services.TTLSessionStorage.createTTLSession({ data: { account: option.account, createAt: new Date() }, ttl: option.ttl || this.settings.authentication.ttl || 1000 * 60 * 60 * 24 * 7 }, (err, id) => { if (err) { callback(err) return } callback(null, id) }) } deleteToken(option: { token: string }, callback: Callback) { this.services.TTLSessionStorage.deleteTTLSession({ id: option.token }, (callback)) } getTokenInfo(option: { token: string }, callback: Callback<{ account: TAccount, createAt: Date }>) { this.services.TTLSessionStorage.getTTLSessionDataById<{ account: TAccount, createAt: Date }>({ id: option.token }, (err, data) => { callback(err, data) }) } initialize(done) { this.authenticationCollection = this.services.MongodbService.db.collection(this.collectionName) this.authenticationCollection.createIndex({ guid: 1 }, { unique: true }) this.authenticationCollection.createIndex({ accountId: 1, type: 1 }, { unique: true }) for (let name in this.definitions) { let def = this.definitions[name] let keys = def.unique() if (keys) { for (let item of keys) { this.authenticationCollection.createIndex(item, { unique: true, sparse: true }) } } } done() } definitions: { [type: string]: AuthenticationDefinition } = {} define(definition: AuthenticationDefinition) { if (this.definitions[definition.type]) { throw new Error(`Definition for authentication ${definition.type} already exists`) } this.definitions[definition.type] = definition } create(option: { authentication: AuthenticationData }, callback: Callback) { let auth = option.authentication if (!auth || !auth.type || !auth.meta) { callback(new Errors.InvalidParameter("Invalid AuthenticationData provided")) return } auth.createdAt = Date.now() let def = this.definitions[auth.type] if (!def) { callback(new Errors.NotFound("No definition found")) return } try { const meta = def.sanitize(auth.meta) if (!meta) { callback(new Errors.InvalidParameter("Authentication can't pass format check")) return } auth.meta = meta } catch (e) { callback(new Errors.InvalidParameter(e.message)) return } auth.guid = def.guid(auth.meta) this.authenticationCollection.insert(auth, (err) => { if (err) { callback(new Errors.AlreadyExists()) return } callback(null, auth) }) } authenticate(option: { authentication: Partial> }, callback: Callback>) { let auth = option.authentication try { let def = this.getDefinition(auth.type) let query = def.authenticateCriteria(auth.meta) if (!query) { callback(new Errors.InvalidParameter("Fail to generate authentication query")) return } this.authenticationCollection.findOne(query, (err, obj) => { if (err) { console.error(err) callback(new Errors.NotFound("Database return error")) return } if (!obj) { callback(new Errors.NotFound) return } delete obj["_id"] obj.meta = def.clearSensitiveInformation(obj.meta) callback(null, obj as AuthenticationData) }) } catch (e) { callback(new Errors.InvalidParameter(e.message)) return } } get(option: { authentication: Partial> }, callback: Callback>) { let auth = option.authentication try { let def = this.getDefinition(auth.type) let query = def.queryCriteria(auth.meta) if (!query) { callback(new Errors.InvalidParameter("Fail to generate authentication query")) return } this.authenticationCollection.findOne(query, (err, obj) => { if (err) { console.error(err) callback(new Errors.NotFound("Database return error")) return } if (!obj) { callback(new Errors.NotFound) return } delete obj["_id"] obj.meta = def.clearSensitiveInformation(obj.meta) callback(null, obj as any) }) } catch (e) { callback(new Errors.InvalidParameter(e.message)) return } } update(option: { authentication: AuthenticationData }, callback: Callback>) { let auth = option.authentication let def = this.sanitizeAndGetDefinition(auth) let guid = auth.guid let updates = { $set: {} } for (let name in auth.meta) { updates["$set"]["meta." + name] = auth.meta[name] } if (auth.accountId) { updates["$set"]["accountId"] = auth.accountId } this.authenticationCollection.findOneAndUpdate({ guid }, updates, (err, result) => { if (err || result.ok !== 1) { callback(new Errors.NotFound) return } callback(null, auth) }) } findByAccountId(accountId: string, callback: Callback) { let cursor = this.authenticationCollection.find({ accountId }) //@ts-ignore: typescript generic bug cursor.toArray((err, objs: AuthenticationData[]) => { if (err || !objs) { callback(new Errors.NotFound) return } objs.map(obj => { let def = this.getDefinition(obj.type as any) if (def) { obj.meta = def.sanitize(obj.meta) obj.meta = def.clearSensitiveInformation(obj.meta) } return obj }) callback(null, objs) }) } private sanitizeAndGetDefinition(auth: AuthenticationData): AuthenticationDefinition { if (!auth || !auth.type) { throw new Errors.InvalidParameter() } let def = this.getDefinition(auth.type) if (!def) { throw new Errors.NotFound("Unknown authentication type") } let meta = def.sanitize(auth.meta) if (!meta) { throw new Errors.InvalidParameter("Authentication meta fail to pass authenticaion sanitization") } auth.meta = meta return def } getDefinition(type: string): AuthenticationDefinition { if (!type) return null return this.definitions[type] || null } } export abstract class AuthenticationDefinition { abstract type: T["type"] // throw, return falsy value means invalid data abstract sanitize(meta: T["meta"]): T["meta"] abstract queryCriteria(meta: T["meta"]): any abstract authenticateCriteria(meta: T["meta"]): any abstract clearSensitiveInformation(meta: T["meta"]): T["meta"] abstract guid(meta: T["meta"]): string abstract about(meta: T["meta"], field: string): any abstract unique(): { [key: string]: number }[] meta: T["meta"] abstract props(meta: T["meta"]): any abouts(meta: T["meta"], fields: string[]) { let v = {} for (let field of fields) { v[field] = this.about(meta, field) } return v } } export class CommonAuthenticationDefinition extends AuthenticationDefinition { constructor(public option: { salt?: string maxNameLength?: number minNameLength?: number maxEmailLength?: number minEmailLength?: number minPasswordLength?: number maxPasswordLength?: number emailValidator?: RegExp nameValidator?: RegExp passwordChecker?: (string) => boolean requireActivation?: boolean } = {}) { super() this.option = Leaf.Util.buildOption({ salt: "e7ac4793242f40ec", maxNameLength: 64, minNameLength: 3, maxEmailLength: 128, minEmailLength: 6, minPasswordLength: 3, maxPasswordLength: 1024, nameValidator: /^[0-9a-z_]+$/i, emailValidator: /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, passwordChecker: (o) => true, requireActivation: false }, this.option as any) } readonly type = "common" sanitize(meta: this["meta"]) { let copy = { ...meta as Object } as this["meta"] for (let field of ["name", "email"]) { if (!meta[field]) throw new Errors.InvalidParameter("Missing " + field) } if (!meta.displayName) { meta.displayName = meta.name } meta.name = meta.name.toLowerCase() if (meta.name.length > this.option.maxNameLength) { throw new Errors.InvalidParameter(`Name length exceed max length ${this.option.maxNameLength}`) } if (meta.name.length < this.option.minNameLength) { throw new Errors.InvalidParameter(`Name length exceed min length ${this.option.minNameLength}`) } if (!this.option.nameValidator.test(meta.name)) { throw new Errors.InvalidParameter("Invalid name, name can only contains 0-9, a-z and _") } if (meta.email.length > this.option.maxEmailLength) { throw new Errors.InvalidParameter(`Email length exceed max length ${this.option.maxEmailLength}`) } if (meta.email.length < this.option.minEmailLength) { throw new Errors.InvalidParameter(`Email length exceed min length ${this.option.minEmailLength}`) } if (!this.option.emailValidator.test(meta.email)) { throw new Errors.InvalidParameter("Invalid email formation") } if (meta.password) { if (meta.password.length > this.option.maxPasswordLength) { throw new Errors.InvalidParameter(`Password length exceed max length ${this.option.maxPasswordLength}`) } if (meta.password.length < this.option.minPasswordLength) { throw new Errors.InvalidParameter(`Password length exceed min length ${this.option.minPasswordLength}`) } if (!this.option.passwordChecker(meta.password)) { throw new Errors.InvalidParameter(`Password too simple`) } } if (meta.password) { delete copy["password"] copy.passwordHash = this.hash(meta.password) } if (!this.option.requireActivation) [ copy.active = true ] return copy } queryCriteria(meta: this["meta"]) { let hash if (!meta.name && !meta.email) { throw new Errors.InvalidParameter("No valid accountnamd and email provided") } let query = {} as any if (meta.email) { query = { ...query, "meta.email": meta.email } } else if (meta.name) { query = { ...query, "meta.name": meta.name } } return query } authenticateCriteria(meta: this["meta"]) { let hash if (meta.password) { hash = this.hash(meta.password) } else if (meta.passwordHash) { hash = meta.passwordHash } else { throw new Errors.InvalidParameter("No valid password/password has privided") } if (!meta.name && !meta.email) { throw new Errors.InvalidParameter("No valid accountnamd and email provided") } let query = { "meta.passwordHash": hash } as any if (meta.email) { query = { ...query, "meta.email": meta.email } } else if (meta.name) { query = { ...query, "meta.name": meta.name } } return query } props(meta: this["meta"]) { return { email: meta.email, name: meta.name } } clearSensitiveInformation(meta: this["meta"]) { let copy = { ...meta as any } as this["meta"] delete copy.password delete copy.passwordHash return copy } guid(meta: this["meta"]) { return crypto.createHash("sha1").update(meta.name + meta.email).update(this.option.salt).digest("hex") } unique() { return [{ "meta.email": 1 }, { "meta.name": 1 }] } about(meta: this["meta"], field: string): any { if (field === "displayName") return meta.displayName || meta.name if (field === "email") return meta.email if (field === "name") return meta.name return null } private hash(raw: string) { // 2 level hash creates the possibility that client don't give password to server. // Only the sha1 content are transfered let h1 = crypto.createHash("sha1").update(raw).digest("hex") let h2 = crypto.createHash("sha1").update(h1).update(this.option.salt).digest("hex") return h2 } }