@nu-art/user-account-backend 0.400.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SlackReporter.d.ts +8 -0
- package/SlackReporter.js +27 -0
- package/_entity/account/ModuleBE_AccountDB.d.ts +97 -0
- package/_entity/account/ModuleBE_AccountDB.js +333 -0
- package/_entity/account/ModuleBE_SAML.d.ts +37 -0
- package/_entity/account/ModuleBE_SAML.js +92 -0
- package/_entity/account/index.d.ts +3 -0
- package/_entity/account/index.js +3 -0
- package/_entity/account/module-pack.d.ts +3 -0
- package/_entity/account/module-pack.js +7 -0
- package/_entity/failed-login-attempt/ModuleBE_FailedLoginAttemptDB.d.ts +50 -0
- package/_entity/failed-login-attempt/ModuleBE_FailedLoginAttemptDB.js +105 -0
- package/_entity/failed-login-attempt/index.d.ts +2 -0
- package/_entity/failed-login-attempt/index.js +2 -0
- package/_entity/failed-login-attempt/module-pack.d.ts +1 -0
- package/_entity/failed-login-attempt/module-pack.js +3 -0
- package/_entity/login-attempts/ModuleBE_LoginAttemptDB.d.ts +36 -0
- package/_entity/login-attempts/ModuleBE_LoginAttemptDB.js +51 -0
- package/_entity/login-attempts/dispatchers.d.ts +5 -0
- package/_entity/login-attempts/dispatchers.js +2 -0
- package/_entity/login-attempts/index.d.ts +2 -0
- package/_entity/login-attempts/index.js +2 -0
- package/_entity/login-attempts/module-pack.d.ts +1 -0
- package/_entity/login-attempts/module-pack.js +3 -0
- package/_entity/session/ModuleBE_JWT.d.ts +46 -0
- package/_entity/session/ModuleBE_JWT.js +86 -0
- package/_entity/session/ModuleBE_SessionDB.d.ts +77 -0
- package/_entity/session/ModuleBE_SessionDB.js +233 -0
- package/_entity/session/consts.d.ts +22 -0
- package/_entity/session/consts.js +33 -0
- package/_entity/session/index.d.ts +3 -0
- package/_entity/session/index.js +3 -0
- package/_entity/session/module-pack.d.ts +2 -0
- package/_entity/session/module-pack.js +2 -0
- package/_entity.d.ts +8 -0
- package/_entity.js +8 -0
- package/index.d.ts +3 -0
- package/index.js +20 -0
- package/module-pack.d.ts +3 -0
- package/module-pack.js +35 -0
- package/package.json +81 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Logger } from '@nu-art/ts-common';
|
|
2
|
+
export declare class SlackReporter extends Logger {
|
|
3
|
+
private fallbackChannel;
|
|
4
|
+
report: string;
|
|
5
|
+
constructor(report: string);
|
|
6
|
+
sendReportToUser: (channel?: string) => Promise<void>;
|
|
7
|
+
sendReportToChannel: (channel?: string) => Promise<void>;
|
|
8
|
+
}
|
package/SlackReporter.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Logger } from '@nu-art/ts-common';
|
|
2
|
+
import { ModuleBE_Slack } from '@nu-art/slack-backend';
|
|
3
|
+
import { MemKey_AccountEmail } from './_entity/session/index.js';
|
|
4
|
+
export class SlackReporter extends Logger {
|
|
5
|
+
fallbackChannel = ModuleBE_Slack.getDefaultChannel();
|
|
6
|
+
report;
|
|
7
|
+
constructor(report) {
|
|
8
|
+
super('SlackReporter');
|
|
9
|
+
this.report = report;
|
|
10
|
+
}
|
|
11
|
+
sendReportToUser = async (channel) => {
|
|
12
|
+
try {
|
|
13
|
+
const userId = await ModuleBE_Slack.getUserIdByEmail(MemKey_AccountEmail.get());
|
|
14
|
+
if (!userId)
|
|
15
|
+
return this.sendReportToChannel(channel);
|
|
16
|
+
const dmId = await ModuleBE_Slack.openDM([userId]);
|
|
17
|
+
await ModuleBE_Slack.postMessage(this.report, dmId);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
this.logError('Failed to send report to user.\nSending to channel instead.\n', err);
|
|
21
|
+
return this.sendReportToChannel(channel);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
sendReportToChannel = async (channel) => {
|
|
25
|
+
await ModuleBE_Slack.postMessage(this.report, channel ?? this.fallbackChannel);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { DB_BaseObject, Dispatcher } from '@nu-art/ts-common';
|
|
2
|
+
import { firestore } from 'firebase-admin';
|
|
3
|
+
import { ModuleBE_BaseDB } from '@nu-art/thunderstorm-backend';
|
|
4
|
+
import { FirestoreQuery } from '@nu-art/firebase-shared';
|
|
5
|
+
import { _SessionKey_Account, Account_ChangePassword, Account_ChangeThumbnail, Account_CreateAccount, Account_Login, Account_RegisterAccount, Account_SetPassword, AccountEmail, AccountEmailWithDevice, AccountToAssertPassword, AccountToSpice, AccountType, DB_Account, DBProto_Account, PasswordAssertionConfig, SafeDB_Account, UI_Account } from '@nu-art/user-account-shared';
|
|
6
|
+
import { BaseSessionClaims, CollectSessionData } from '../session/index.js';
|
|
7
|
+
import Transaction = firestore.Transaction;
|
|
8
|
+
type BaseAccount = {
|
|
9
|
+
email: string;
|
|
10
|
+
type: AccountType;
|
|
11
|
+
};
|
|
12
|
+
type SpicedAccount = BaseAccount & {
|
|
13
|
+
salt: string;
|
|
14
|
+
saltedPassword: string;
|
|
15
|
+
};
|
|
16
|
+
type AccountToCreate = SpicedAccount | BaseAccount;
|
|
17
|
+
export interface OnNewUserRegistered {
|
|
18
|
+
__onNewUserRegistered(account: SafeDB_Account, transaction: Transaction): void;
|
|
19
|
+
}
|
|
20
|
+
export interface OnUserLogin {
|
|
21
|
+
__onUserLogin(account: SafeDB_Account, transaction: Transaction): void;
|
|
22
|
+
}
|
|
23
|
+
export interface OnPreLogout {
|
|
24
|
+
__onPreLogout: () => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare const dispatch_onAccountLogin: Dispatcher<OnUserLogin, "__onUserLogin", [account: SafeDB_Account, transaction: firestore.Transaction], void>;
|
|
27
|
+
export declare const dispatch_onPreLogout: Dispatcher<OnPreLogout, "__onPreLogout", [], void>;
|
|
28
|
+
type Config = {
|
|
29
|
+
canRegister: boolean;
|
|
30
|
+
passwordAssertion?: PasswordAssertionConfig;
|
|
31
|
+
ignorePasswordAssertion?: boolean;
|
|
32
|
+
};
|
|
33
|
+
export declare class ModuleBE_AccountDB_Class extends ModuleBE_BaseDB<DBProto_Account, Config> implements CollectSessionData<_SessionKey_Account> {
|
|
34
|
+
readonly Middleware: () => Promise<void>;
|
|
35
|
+
constructor();
|
|
36
|
+
init(): void;
|
|
37
|
+
manipulateQuery(query: FirestoreQuery<DB_Account>): FirestoreQuery<DB_Account>;
|
|
38
|
+
canDeleteItems(dbItems: DB_Account[], transaction?: FirebaseFirestore.Transaction): Promise<void>;
|
|
39
|
+
__collectSessionData(data: BaseSessionClaims): Promise<{
|
|
40
|
+
key: "account";
|
|
41
|
+
value: {
|
|
42
|
+
hasPassword: boolean;
|
|
43
|
+
type: AccountType;
|
|
44
|
+
email: string;
|
|
45
|
+
description?: string | undefined;
|
|
46
|
+
displayName?: string | undefined;
|
|
47
|
+
thumbnail?: string | undefined;
|
|
48
|
+
_id: string;
|
|
49
|
+
__metadata1?: any;
|
|
50
|
+
__hardDelete?: boolean;
|
|
51
|
+
__created?: number | undefined;
|
|
52
|
+
__updated?: number | undefined;
|
|
53
|
+
_v?: string;
|
|
54
|
+
_originDocId?: import("@nu-art/ts-common").UniqueId;
|
|
55
|
+
salt?: string | undefined;
|
|
56
|
+
_auditorId?: string | undefined;
|
|
57
|
+
_newPasswordRequired?: boolean | undefined;
|
|
58
|
+
saltedPassword?: string | undefined;
|
|
59
|
+
};
|
|
60
|
+
}>;
|
|
61
|
+
protected preWriteProcessing(dbInstance: UI_Account, originalDbInstance: DBProto_Account['dbType'], transaction?: Transaction): Promise<void>;
|
|
62
|
+
impl: {
|
|
63
|
+
fixEmail: (objectWithEmail: {
|
|
64
|
+
email: string;
|
|
65
|
+
}) => void;
|
|
66
|
+
assertPasswordCheck: (accountToAssert: AccountToAssertPassword) => void;
|
|
67
|
+
spiceAccount: (accountToSpice: AccountToSpice) => SpicedAccount;
|
|
68
|
+
create: (accountToCreate: AccountToCreate, transaction: Transaction) => Promise<SafeDB_Account>;
|
|
69
|
+
setAccountMemKeys: (account: SafeDB_Account) => Promise<void>;
|
|
70
|
+
onAccountCreated: (account: SafeDB_Account, transaction: Transaction) => Promise<void>;
|
|
71
|
+
onAccountLogin: (account: SafeDB_Account, transaction: Transaction) => Promise<void>;
|
|
72
|
+
queryUnsafeAccount: (credentials: AccountEmail, transaction?: Transaction) => Promise<DB_Account>;
|
|
73
|
+
querySafeAccount: (credentials: AccountEmail, transaction?: Transaction) => Promise<SafeDB_Account>;
|
|
74
|
+
};
|
|
75
|
+
account: {
|
|
76
|
+
register: (accountWithPassword: Account_RegisterAccount["request"], transaction?: Transaction) => Promise<Account_RegisterAccount["response"]>;
|
|
77
|
+
login: (credentials: Account_Login["request"]) => Promise<Account_Login["response"]>;
|
|
78
|
+
create: (createAccountRequest: Account_CreateAccount["request"]) => Promise<Account_CreateAccount["response"]>;
|
|
79
|
+
saml: (oAuthAccount: AccountEmailWithDevice) => Promise<import("@nu-art/user-account-shared").DB_Session>;
|
|
80
|
+
changePassword: (passwordToChange: Account_ChangePassword["request"]) => Promise<Account_ChangePassword["response"]>;
|
|
81
|
+
setPassword: (passwordBody: Account_SetPassword["request"]) => Promise<Account_SetPassword["response"]>;
|
|
82
|
+
logout: () => Promise<void>;
|
|
83
|
+
getSessions: (query: DB_BaseObject) => Promise<{
|
|
84
|
+
sessions: import("@nu-art/user-account-shared").DB_Session[];
|
|
85
|
+
}>;
|
|
86
|
+
changeThumbnail: (request: Account_ChangeThumbnail["request"]) => Promise<Account_ChangeThumbnail["response"]>;
|
|
87
|
+
};
|
|
88
|
+
password: {
|
|
89
|
+
assertPasswordExistence: (email: string, password?: string, passwordCheck?: string) => void;
|
|
90
|
+
assertPasswordRules: (password: string) => void;
|
|
91
|
+
assertPasswordMatch: (safeAccount: SafeDB_Account, password: string) => Promise<void>;
|
|
92
|
+
};
|
|
93
|
+
private token;
|
|
94
|
+
}
|
|
95
|
+
export declare function makeAccountSafe(account: DB_Account): SafeDB_Account;
|
|
96
|
+
export declare const ModuleBE_AccountDB: ModuleBE_AccountDB_Class;
|
|
97
|
+
export {};
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { ApiException, BadImplementationException, cloneObj, compare, dispatch_onApplicationException, Dispatcher, exists, generateHex, hashPasswordWithSalt, md5, MUSTNeverHappenException, Year } from '@nu-art/ts-common';
|
|
2
|
+
import { addRoutes, createBodyServerApi, createQueryServerApi, ModuleBE_BaseDB } from '@nu-art/thunderstorm-backend';
|
|
3
|
+
import { FirestoreInterfaceV3 } from '@nu-art/firebase-backend/firestore-v3/FirestoreInterfaceV3';
|
|
4
|
+
import { HttpCodes } from '@nu-art/ts-common/core/exceptions/http-codes';
|
|
5
|
+
import { ApiDef_Account, assertPasswordRules, DBDef_Accounts } from '@nu-art/user-account-shared';
|
|
6
|
+
import { Header_Authorization, MemKey_AccountEmail, MemKey_AccountId, MemKey_AccountType, MemKey_DB_Session, ModuleBE_SessionDB, SessionKey_Account_BE, } from '../session/index.js';
|
|
7
|
+
import { ModuleBE_FailedLoginAttemptDB } from '../failed-login-attempt/index.js';
|
|
8
|
+
export const dispatch_onAccountLogin = new Dispatcher('__onUserLogin');
|
|
9
|
+
const dispatch_onAccountRegistered = new Dispatcher('__onNewUserRegistered');
|
|
10
|
+
export const dispatch_onPreLogout = new Dispatcher('__onPreLogout');
|
|
11
|
+
export class ModuleBE_AccountDB_Class extends ModuleBE_BaseDB {
|
|
12
|
+
Middleware = async () => {
|
|
13
|
+
const account = SessionKey_Account_BE.get();
|
|
14
|
+
MemKey_AccountEmail.set(account.email);
|
|
15
|
+
MemKey_AccountId.set(account._id);
|
|
16
|
+
MemKey_AccountType.set(account.type);
|
|
17
|
+
};
|
|
18
|
+
constructor() {
|
|
19
|
+
super(DBDef_Accounts);
|
|
20
|
+
}
|
|
21
|
+
init() {
|
|
22
|
+
super.init();
|
|
23
|
+
addRoutes([
|
|
24
|
+
createQueryServerApi(ApiDef_Account._v1.refreshSession, async () => {
|
|
25
|
+
this.logInfo(`Refreshing session for account id = ${MemKey_AccountId.get()}`);
|
|
26
|
+
}),
|
|
27
|
+
createBodyServerApi(ApiDef_Account._v1.registerAccount, this.account.register),
|
|
28
|
+
createBodyServerApi(ApiDef_Account._v1.changePassword, this.account.changePassword),
|
|
29
|
+
createBodyServerApi(ApiDef_Account._v1.login, this.account.login),
|
|
30
|
+
createBodyServerApi(ApiDef_Account._v1.createAccount, this.account.create),
|
|
31
|
+
createQueryServerApi(ApiDef_Account._v1.logout, this.account.logout),
|
|
32
|
+
createBodyServerApi(ApiDef_Account._v1.createToken, this.token.create),
|
|
33
|
+
createBodyServerApi(ApiDef_Account._v1.setPassword, this.account.setPassword),
|
|
34
|
+
createQueryServerApi(ApiDef_Account._v1.getSessions, this.account.getSessions),
|
|
35
|
+
createBodyServerApi(ApiDef_Account._v1.changeThumbnail, this.account.changeThumbnail),
|
|
36
|
+
createQueryServerApi(ApiDef_Account._v1.getPasswordAssertionConfig, async () => ({
|
|
37
|
+
config: this.config.ignorePasswordAssertion
|
|
38
|
+
? undefined
|
|
39
|
+
: this.config.passwordAssertion
|
|
40
|
+
}))
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
manipulateQuery(query) {
|
|
44
|
+
return {
|
|
45
|
+
...query,
|
|
46
|
+
select: ['__created', '_v', '__updated', 'email', '_newPasswordRequired', 'type', '_id', 'thumbnail', 'displayName', '_auditorId', 'description']
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
canDeleteItems(dbItems, transaction) {
|
|
50
|
+
throw HttpCodes._5XX.NOT_IMPLEMENTED('Account Deletion is not implemented yet');
|
|
51
|
+
}
|
|
52
|
+
async __collectSessionData(data) {
|
|
53
|
+
const account = await this.query.uniqueAssert(data.accountId);
|
|
54
|
+
return {
|
|
55
|
+
key: 'account',
|
|
56
|
+
value: {
|
|
57
|
+
...makeAccountSafe(account),
|
|
58
|
+
hasPassword: !!account.saltedPassword,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async preWriteProcessing(dbInstance, originalDbInstance, transaction) {
|
|
63
|
+
try {
|
|
64
|
+
dbInstance._auditorId = MemKey_AccountId.get();
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
dbInstance._auditorId = dbInstance._id;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
impl = {
|
|
71
|
+
fixEmail: (objectWithEmail) => {
|
|
72
|
+
objectWithEmail.email = objectWithEmail.email.toLowerCase();
|
|
73
|
+
},
|
|
74
|
+
assertPasswordCheck: (accountToAssert) => {
|
|
75
|
+
this.password.assertPasswordExistence(accountToAssert.email, accountToAssert.password, accountToAssert.passwordCheck);
|
|
76
|
+
this.password.assertPasswordRules(accountToAssert.password);
|
|
77
|
+
},
|
|
78
|
+
spiceAccount: (accountToSpice) => {
|
|
79
|
+
const salt = generateHex(32);
|
|
80
|
+
return {
|
|
81
|
+
email: accountToSpice.email,
|
|
82
|
+
type: 'user',
|
|
83
|
+
salt,
|
|
84
|
+
saltedPassword: hashPasswordWithSalt(salt, accountToSpice.password)
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
create: async (accountToCreate, transaction) => {
|
|
88
|
+
let dbAccount = (await this.query.custom({
|
|
89
|
+
where: { email: accountToCreate.email },
|
|
90
|
+
limit: 1
|
|
91
|
+
}, transaction))[0];
|
|
92
|
+
if (dbAccount)
|
|
93
|
+
throw new ApiException(422, `User with email "${accountToCreate.email}" already exists`);
|
|
94
|
+
dbAccount = await this.create.item(accountToCreate, transaction);
|
|
95
|
+
return makeAccountSafe(dbAccount);
|
|
96
|
+
},
|
|
97
|
+
setAccountMemKeys: async (account) => {
|
|
98
|
+
MemKey_AccountId.set(account._id);
|
|
99
|
+
MemKey_AccountEmail.set(account.email);
|
|
100
|
+
},
|
|
101
|
+
onAccountCreated: async (account, transaction) => {
|
|
102
|
+
await dispatch_onAccountRegistered.dispatchModuleAsync(account, transaction);
|
|
103
|
+
},
|
|
104
|
+
onAccountLogin: async (account, transaction) => {
|
|
105
|
+
await dispatch_onAccountLogin.dispatchModuleAsync(account, transaction);
|
|
106
|
+
},
|
|
107
|
+
queryUnsafeAccount: async (credentials, transaction) => {
|
|
108
|
+
const firestoreQuery = FirestoreInterfaceV3.buildQuery(this.collection, { where: { email: credentials.email } });
|
|
109
|
+
let results;
|
|
110
|
+
if (transaction)
|
|
111
|
+
results = (await transaction.get(firestoreQuery)).docs;
|
|
112
|
+
else
|
|
113
|
+
results = (await firestoreQuery.get()).docs;
|
|
114
|
+
if (results.length !== 1)
|
|
115
|
+
if (results.length === 0) {
|
|
116
|
+
const apiException = new ApiException(401, `There is no account for email '${credentials.email}'.`);
|
|
117
|
+
await dispatch_onApplicationException.dispatchModuleAsync(apiException, this);
|
|
118
|
+
throw apiException;
|
|
119
|
+
}
|
|
120
|
+
else if (results.length > 1) {
|
|
121
|
+
this.logWarningBold(`Too many accounts using this email! '${credentials.email}'`);
|
|
122
|
+
throw new MUSTNeverHappenException('Too many accounts using this email');
|
|
123
|
+
}
|
|
124
|
+
return results[0].data();
|
|
125
|
+
},
|
|
126
|
+
querySafeAccount: async (credentials, transaction) => {
|
|
127
|
+
const account = await this.impl.queryUnsafeAccount(credentials, transaction);
|
|
128
|
+
return makeAccountSafe(account);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
account = {
|
|
132
|
+
// this flow is for creating real human users with email and password
|
|
133
|
+
register: async (accountWithPassword, transaction) => {
|
|
134
|
+
if (!this.config.canRegister)
|
|
135
|
+
throw new ApiException(418, 'Registration is disabled!!');
|
|
136
|
+
this.impl.fixEmail(accountWithPassword);
|
|
137
|
+
this.impl.assertPasswordCheck(accountWithPassword);
|
|
138
|
+
const spicedAccount = this.impl.spiceAccount({
|
|
139
|
+
email: accountWithPassword.email,
|
|
140
|
+
password: accountWithPassword.password
|
|
141
|
+
});
|
|
142
|
+
const dbSafeAccount = await this.runTransaction(async (transaction) => {
|
|
143
|
+
const dbSafeAccount = await this.impl.create(spicedAccount, transaction);
|
|
144
|
+
await this.impl.setAccountMemKeys(dbSafeAccount);
|
|
145
|
+
await this.impl.onAccountCreated(dbSafeAccount, transaction);
|
|
146
|
+
return dbSafeAccount;
|
|
147
|
+
});
|
|
148
|
+
await this.account.login({
|
|
149
|
+
email: accountWithPassword.email,
|
|
150
|
+
deviceId: accountWithPassword.deviceId,
|
|
151
|
+
password: accountWithPassword.password
|
|
152
|
+
});
|
|
153
|
+
return { ...dbSafeAccount };
|
|
154
|
+
},
|
|
155
|
+
login: async (credentials) => {
|
|
156
|
+
this.impl.fixEmail(credentials);
|
|
157
|
+
const safeAccount = await this.runTransaction(async (transaction) => {
|
|
158
|
+
const dbAccount = await this.impl.queryUnsafeAccount({ email: credentials.email }, transaction);
|
|
159
|
+
await this.password.assertPasswordMatch(dbAccount, credentials.password);
|
|
160
|
+
const safeAccount = makeAccountSafe(dbAccount);
|
|
161
|
+
MemKey_AccountId.set(safeAccount._id);
|
|
162
|
+
await this.impl.onAccountLogin(safeAccount, transaction);
|
|
163
|
+
return safeAccount;
|
|
164
|
+
});
|
|
165
|
+
const initialClaims = {
|
|
166
|
+
accountId: safeAccount._id,
|
|
167
|
+
deviceId: credentials.deviceId,
|
|
168
|
+
label: 'password-login'
|
|
169
|
+
};
|
|
170
|
+
await ModuleBE_SessionDB._session.create.andReturn({ initialClaims });
|
|
171
|
+
return safeAccount;
|
|
172
|
+
},
|
|
173
|
+
create: async (createAccountRequest) => {
|
|
174
|
+
const password = createAccountRequest.password;
|
|
175
|
+
let dbSafeAccount;
|
|
176
|
+
this.impl.fixEmail(createAccountRequest);
|
|
177
|
+
return this.runTransaction(async (transaction) => {
|
|
178
|
+
if (exists(password) || exists(createAccountRequest.passwordCheck)) {
|
|
179
|
+
this.impl.assertPasswordCheck(createAccountRequest);
|
|
180
|
+
const spicedAccount = this.impl.spiceAccount(createAccountRequest);
|
|
181
|
+
dbSafeAccount = await this.impl.create(spicedAccount, transaction);
|
|
182
|
+
}
|
|
183
|
+
else
|
|
184
|
+
dbSafeAccount = await this.impl.create(createAccountRequest, transaction);
|
|
185
|
+
await this.impl.onAccountCreated(dbSafeAccount, transaction);
|
|
186
|
+
return dbSafeAccount;
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
saml: async (oAuthAccount) => {
|
|
190
|
+
this.impl.fixEmail(oAuthAccount);
|
|
191
|
+
const dbSafeAccount = await this.runTransaction(async (transaction) => {
|
|
192
|
+
let dbSafeAccount;
|
|
193
|
+
try {
|
|
194
|
+
dbSafeAccount = await this.impl.querySafeAccount({ ...oAuthAccount }, transaction);
|
|
195
|
+
this.logInfo('SAML login account');
|
|
196
|
+
MemKey_AccountId.set(dbSafeAccount._id);
|
|
197
|
+
await this.impl.onAccountLogin(dbSafeAccount, transaction);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
if (e.responseCode !== 401)
|
|
201
|
+
throw e;
|
|
202
|
+
this.logInfo('SAML register account');
|
|
203
|
+
dbSafeAccount = await this.impl.create({ email: oAuthAccount.email, type: 'user' }, transaction);
|
|
204
|
+
MemKey_AccountId.set(dbSafeAccount._id);
|
|
205
|
+
await this.impl.onAccountCreated(dbSafeAccount, transaction);
|
|
206
|
+
}
|
|
207
|
+
return dbSafeAccount;
|
|
208
|
+
});
|
|
209
|
+
const initialClaims = { accountId: dbSafeAccount._id, deviceId: oAuthAccount.deviceId, label: 'saml-login' };
|
|
210
|
+
return ModuleBE_SessionDB._session.create({ initialClaims });
|
|
211
|
+
},
|
|
212
|
+
changePassword: async (passwordToChange) => {
|
|
213
|
+
return this.runTransaction(async (transaction) => {
|
|
214
|
+
const email = MemKey_AccountEmail.get();
|
|
215
|
+
const deviceId = MemKey_DB_Session.get().deviceId;
|
|
216
|
+
await this.account.login({ email, deviceId, password: passwordToChange.oldPassword }); // perform login to make sure the old password holds
|
|
217
|
+
if (!compare(passwordToChange.password, passwordToChange.passwordCheck))
|
|
218
|
+
throw HttpCodes._4XX.UNAUTHORIZED('Password check mismatch');
|
|
219
|
+
const safeAccount = await this.impl.querySafeAccount({ email });
|
|
220
|
+
this.impl.assertPasswordCheck({
|
|
221
|
+
email,
|
|
222
|
+
password: passwordToChange.password,
|
|
223
|
+
passwordCheck: passwordToChange.passwordCheck
|
|
224
|
+
});
|
|
225
|
+
const spicedAccount = this.impl.spiceAccount({ email, password: passwordToChange.password });
|
|
226
|
+
const updatedAccount = await this.set.item({
|
|
227
|
+
...safeAccount,
|
|
228
|
+
salt: spicedAccount.salt,
|
|
229
|
+
saltedPassword: spicedAccount.saltedPassword
|
|
230
|
+
}, transaction);
|
|
231
|
+
const initialClaims = {
|
|
232
|
+
accountId: updatedAccount._id,
|
|
233
|
+
deviceId,
|
|
234
|
+
label: 'password-change'
|
|
235
|
+
};
|
|
236
|
+
await ModuleBE_SessionDB._session.create.andReturn({ initialClaims });
|
|
237
|
+
return makeAccountSafe(updatedAccount);
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
setPassword: async (passwordBody) => {
|
|
241
|
+
return this.runTransaction(async (transaction) => {
|
|
242
|
+
const email = MemKey_AccountEmail.get();
|
|
243
|
+
const deviceId = MemKey_DB_Session.get().deviceId;
|
|
244
|
+
const dbAccount = await this.impl.queryUnsafeAccount({ email }, transaction);
|
|
245
|
+
if (dbAccount.saltedPassword)
|
|
246
|
+
throw HttpCodes._4XX.FORBIDDEN('account already has password');
|
|
247
|
+
const safeAccount = makeAccountSafe(dbAccount);
|
|
248
|
+
this.impl.assertPasswordCheck({ email, ...passwordBody });
|
|
249
|
+
const spicedAccount = this.impl.spiceAccount({ email, password: passwordBody.password });
|
|
250
|
+
const updatedAccount = await this.set.item({
|
|
251
|
+
...safeAccount,
|
|
252
|
+
salt: spicedAccount.salt,
|
|
253
|
+
saltedPassword: spicedAccount.saltedPassword
|
|
254
|
+
}, transaction);
|
|
255
|
+
const initialClaims = {
|
|
256
|
+
accountId: updatedAccount._id,
|
|
257
|
+
deviceId,
|
|
258
|
+
label: 'password-set'
|
|
259
|
+
};
|
|
260
|
+
await ModuleBE_SessionDB._session.create.andReturn({ initialClaims });
|
|
261
|
+
return makeAccountSafe(updatedAccount);
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
logout: async () => {
|
|
265
|
+
const sessionId = Header_Authorization.get();
|
|
266
|
+
if (!sessionId)
|
|
267
|
+
throw HttpCodes._4XX.FORBIDDEN('Missing sessionId');
|
|
268
|
+
await dispatch_onPreLogout.dispatchModuleAsync();
|
|
269
|
+
await ModuleBE_SessionDB._session.invalidate.bySession();
|
|
270
|
+
},
|
|
271
|
+
getSessions: async (query) => {
|
|
272
|
+
return { sessions: await ModuleBE_SessionDB.query.where({ accountId: query._id }) };
|
|
273
|
+
},
|
|
274
|
+
changeThumbnail: async (request) => {
|
|
275
|
+
const account = await this.doc.unique(request.accountId);
|
|
276
|
+
if (!account)
|
|
277
|
+
throw HttpCodes._4XX.NOT_FOUND('Could not change account thumbnail', `Could not find account with id ${request.accountId}`);
|
|
278
|
+
await account.ref.update({ thumbnail: request.hash });
|
|
279
|
+
return {
|
|
280
|
+
account: (await account.get()),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
password = {
|
|
285
|
+
assertPasswordExistence: (email, password, passwordCheck) => {
|
|
286
|
+
if (!password || !passwordCheck)
|
|
287
|
+
throw HttpCodes._4XX.BAD_REQUEST(`Did not receive a password`, `Did not receive a password for email ${email}.`);
|
|
288
|
+
if (password !== passwordCheck)
|
|
289
|
+
throw HttpCodes._4XX.BAD_REQUEST(`Password check does not match`, `Password does not match password check for email ${email}.`);
|
|
290
|
+
},
|
|
291
|
+
assertPasswordRules: (password) => {
|
|
292
|
+
const assertPassword = assertPasswordRules(password, this.config.passwordAssertion);
|
|
293
|
+
if (assertPassword)
|
|
294
|
+
throw new ApiException(444, `Password assertion failed`).setErrorBody({
|
|
295
|
+
type: 'password-assertion-error',
|
|
296
|
+
data: assertPassword,
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
assertPasswordMatch: async (safeAccount, password) => {
|
|
300
|
+
if (!safeAccount.salt || !safeAccount.saltedPassword)
|
|
301
|
+
throw new ApiException(401, 'Account was never logged in using username and password, probably logged using SAML');
|
|
302
|
+
if (hashPasswordWithSalt(safeAccount.salt, password) !== safeAccount.saltedPassword) {
|
|
303
|
+
await ModuleBE_FailedLoginAttemptDB.updateFailedLoginAttempt(safeAccount._id); // first update login attempt
|
|
304
|
+
throw new ApiException(401, 'Wrong username or password.');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
// @ts-ignore
|
|
309
|
+
token = {
|
|
310
|
+
create: async ({ accountId, ttl, label }) => {
|
|
311
|
+
if (!exists(ttl) || ttl < Year)
|
|
312
|
+
throw HttpCodes._4XX.BAD_REQUEST('Invalid token TTL', `TTL value is invalid (${ttl})`);
|
|
313
|
+
const account = await this.query.unique(accountId);
|
|
314
|
+
if (!account)
|
|
315
|
+
throw new BadImplementationException(`Account not found for id ${accountId}`);
|
|
316
|
+
if (account.type !== 'service')
|
|
317
|
+
throw new BadImplementationException('Can not generate a token for a non service account');
|
|
318
|
+
const initialClaims = { accountId, deviceId: accountId, label };
|
|
319
|
+
const dbSession = await ModuleBE_SessionDB._session.create({ initialClaims }, ttl);
|
|
320
|
+
// sessionId here is the JWT that is created and placed inside DB_Session.sessionIdJWT
|
|
321
|
+
return { token: dbSession.sessionIdJwt };
|
|
322
|
+
},
|
|
323
|
+
invalidate: async (token) => await ModuleBE_SessionDB.delete.where({ _id: md5(token) }),
|
|
324
|
+
invalidateAll: async (accountId) => await ModuleBE_SessionDB.delete.where({ accountId })
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
export function makeAccountSafe(account) {
|
|
328
|
+
const uiAccount = cloneObj(account);
|
|
329
|
+
delete uiAccount.salt;
|
|
330
|
+
delete uiAccount.saltedPassword;
|
|
331
|
+
return uiAccount;
|
|
332
|
+
}
|
|
333
|
+
export const ModuleBE_AccountDB = new ModuleBE_AccountDB_Class();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { IdentityProvider, IdentityProviderOptions, ServiceProviderOptions } from 'saml2-js';
|
|
2
|
+
import { Module } from '@nu-art/ts-common';
|
|
3
|
+
import { SAML_Assert, SAML_Login } from '@nu-art/user-account-shared';
|
|
4
|
+
/**
|
|
5
|
+
* SAML config, when filling in the RTDB, should look like this:
|
|
6
|
+
* ```
|
|
7
|
+
* ModuleBE_SAML: {
|
|
8
|
+
* idConfig: {
|
|
9
|
+
* sso_login_url: string - the accounts.google url for login
|
|
10
|
+
* sso_logout_url: string - the accounts.google url for login (optional)
|
|
11
|
+
* certificates: string[] - only one necessary, the cert for login
|
|
12
|
+
* ignore_signature: boolean - should be true
|
|
13
|
+
* },
|
|
14
|
+
* spConfig: {
|
|
15
|
+
* allow_unencrypted_assertion: boolean - should be true
|
|
16
|
+
* assert_endpoint: string - the BE endpoint for the account assertion
|
|
17
|
+
* entity_id: string - the entityID from the google SAML project.
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
type SamlConfig = {
|
|
23
|
+
idConfig: IdentityProviderOptions;
|
|
24
|
+
spConfig: ServiceProviderOptions;
|
|
25
|
+
};
|
|
26
|
+
export declare class ModuleBE_SAML_Class extends Module<SamlConfig> {
|
|
27
|
+
identityProvider: IdentityProvider;
|
|
28
|
+
constructor();
|
|
29
|
+
protected init(): void;
|
|
30
|
+
assertSaml: (body: SAML_Assert["request"]) => Promise<SAML_Assert["response"]>;
|
|
31
|
+
loginRequest: (loginContext: SAML_Login["request"]) => Promise<{
|
|
32
|
+
loginUrl: string;
|
|
33
|
+
}>;
|
|
34
|
+
private assertImpl;
|
|
35
|
+
}
|
|
36
|
+
export declare const ModuleBE_SAML: ModuleBE_SAML_Class;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* User secured registration and login management system..
|
|
3
|
+
*
|
|
4
|
+
* Copyright (C) 2020 Adam van der Kruk aka TacB0sS
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
import { IdentityProvider, ServiceProvider } from 'saml2-js';
|
|
19
|
+
import { __stringify, ApiException, decode, ImplementationMissingException, LogLevel, Module, MUSTNeverHappenException } from '@nu-art/ts-common';
|
|
20
|
+
import { addRoutes, createBodyServerApi, createQueryServerApi } from '@nu-art/thunderstorm-backend';
|
|
21
|
+
import { MemKey_HttpResponse } from '@nu-art/thunderstorm-backend/modules/server/consts';
|
|
22
|
+
import { ModuleBE_AccountDB } from './ModuleBE_AccountDB.js';
|
|
23
|
+
import { ApiDef_SAML, QueryParam_Email, QueryParam_RedirectUrl, QueryParam_SessionId } from '@nu-art/user-account-shared';
|
|
24
|
+
import { MemKey_AccountEmail } from '../session/index.js';
|
|
25
|
+
export class ModuleBE_SAML_Class extends Module {
|
|
26
|
+
identityProvider;
|
|
27
|
+
constructor() {
|
|
28
|
+
super();
|
|
29
|
+
this.setMinLevel(LogLevel.Debug);
|
|
30
|
+
}
|
|
31
|
+
init() {
|
|
32
|
+
super.init();
|
|
33
|
+
if (!this.config.idConfig)
|
|
34
|
+
throw new ImplementationMissingException('Config must contain idConfig');
|
|
35
|
+
if (!this.config.spConfig)
|
|
36
|
+
throw new ImplementationMissingException('Config must contain spConfig');
|
|
37
|
+
addRoutes([
|
|
38
|
+
createQueryServerApi(ApiDef_SAML._v1.loginSaml, this.loginRequest),
|
|
39
|
+
createBodyServerApi(ApiDef_SAML._v1.assertSAML, this.assertSaml),
|
|
40
|
+
]);
|
|
41
|
+
this.config.idConfig.certificates = this.config.idConfig.certificates.map(cert => decode(cert));
|
|
42
|
+
this.identityProvider = new IdentityProvider(this.config.idConfig);
|
|
43
|
+
}
|
|
44
|
+
assertSaml = async (body) => {
|
|
45
|
+
try {
|
|
46
|
+
this.logDebug('assertion called with body:', body);
|
|
47
|
+
const data = await this.assertImpl(body);
|
|
48
|
+
this.logDebug(`Got data from assertion ${__stringify(data)}`);
|
|
49
|
+
const accountWithoutPassword = { email: data.userId.toLowerCase(), deviceId: data.loginContext.deviceId, type: 'user' };
|
|
50
|
+
MemKey_AccountEmail.set(accountWithoutPassword.email);
|
|
51
|
+
const dbSession = await ModuleBE_AccountDB.account.saml(accountWithoutPassword);
|
|
52
|
+
let redirectUrl = data.loginContext[QueryParam_RedirectUrl];
|
|
53
|
+
redirectUrl = redirectUrl.replace(new RegExp(QueryParam_SessionId.toUpperCase(), 'g'), encodeURIComponent(dbSession.sessionIdJwt));
|
|
54
|
+
redirectUrl = redirectUrl.replace(new RegExp(QueryParam_Email.toUpperCase(), 'g'), encodeURIComponent(accountWithoutPassword.email));
|
|
55
|
+
MemKey_HttpResponse.get().redirect(302, redirectUrl);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
throw new ApiException(401, 'Error authenticating user', error);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
loginRequest = async (loginContext) => {
|
|
62
|
+
return new Promise((resolve, rejected) => {
|
|
63
|
+
const sp = new ServiceProvider(this.config.spConfig);
|
|
64
|
+
const options = {
|
|
65
|
+
relay_state: __stringify(loginContext)
|
|
66
|
+
};
|
|
67
|
+
sp.create_login_request_url(this.identityProvider, options, (error, loginUrl, requestId) => {
|
|
68
|
+
console.log('SAML 2');
|
|
69
|
+
if (error)
|
|
70
|
+
return rejected(error);
|
|
71
|
+
resolve({ loginUrl });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
assertImpl = async (request_body) => new Promise((resolve, rejected) => {
|
|
76
|
+
const assertBody = { request_body };
|
|
77
|
+
const sp = new ServiceProvider(this.config.spConfig);
|
|
78
|
+
sp.post_assert(this.identityProvider, assertBody, async (error, response) => {
|
|
79
|
+
if (error)
|
|
80
|
+
return rejected(error);
|
|
81
|
+
const relay_state = assertBody.request_body.RelayState;
|
|
82
|
+
if (!relay_state)
|
|
83
|
+
return rejected(new MUSTNeverHappenException('LoginContext lost along the way'));
|
|
84
|
+
resolve({
|
|
85
|
+
userId: response.user.name_id,
|
|
86
|
+
loginContext: JSON.parse(relay_state),
|
|
87
|
+
fullResponse: response
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
export const ModuleBE_SAML = new ModuleBE_SAML_Class();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createApisForDBModuleV3 } from '@nu-art/thunderstorm-backend';
|
|
2
|
+
import { ModuleBE_AccountDB } from './ModuleBE_AccountDB.js';
|
|
3
|
+
import { ModuleBE_SAML } from './ModuleBE_SAML.js';
|
|
4
|
+
export const ModulePackBE_AccountDB = [
|
|
5
|
+
ModuleBE_AccountDB, createApisForDBModuleV3(ModuleBE_AccountDB),
|
|
6
|
+
];
|
|
7
|
+
export const ModulePackBE_SAML = [ModuleBE_SAML];
|