mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 08:09:52 +08:00
refactor(server): auth (#5895)
Remove `next-auth` and implement our own Authorization/Authentication system from scratch.
## Server
- [x] tokens
- [x] function
- [x] encryption
- [x] AuthController
- [x] /api/auth/sign-in
- [x] /api/auth/sign-out
- [x] /api/auth/session
- [x] /api/auth/session (WE SUPPORT MULTI-ACCOUNT!)
- [x] OAuthPlugin
- [x] OAuthController
- [x] /oauth/login
- [x] /oauth/callback
- [x] Providers
- [x] Google
- [x] GitHub
## Client
- [x] useSession
- [x] cloudSignIn
- [x] cloudSignOut
## NOTE:
Tests will be adding in the future
This commit is contained in:
@@ -87,6 +87,22 @@ export interface AFFiNEConfig {
|
||||
sync: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Application secrets for authentication and data encryption
|
||||
*/
|
||||
secrets: {
|
||||
/**
|
||||
* Application public key
|
||||
*
|
||||
*/
|
||||
publicKey: string;
|
||||
/**
|
||||
* Application private key
|
||||
*
|
||||
*/
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deployment environment
|
||||
*/
|
||||
@@ -204,67 +220,32 @@ export interface AFFiNEConfig {
|
||||
* authentication config
|
||||
*/
|
||||
auth: {
|
||||
session: {
|
||||
/**
|
||||
* Application auth expiration time in seconds
|
||||
*
|
||||
* @default 15 days
|
||||
*/
|
||||
ttl: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Application access token expiration time
|
||||
* Application access token config
|
||||
*/
|
||||
readonly accessTokenExpiresIn: number;
|
||||
/**
|
||||
* Application refresh token expiration time
|
||||
*/
|
||||
readonly refreshTokenExpiresIn: number;
|
||||
/**
|
||||
* Add some leeway (in seconds) to the exp and nbf validation to account for clock skew.
|
||||
* Defaults to 60 if omitted.
|
||||
*/
|
||||
readonly leeway: number;
|
||||
/**
|
||||
* Application public key
|
||||
*
|
||||
*/
|
||||
readonly publicKey: string;
|
||||
/**
|
||||
* Application private key
|
||||
*
|
||||
*/
|
||||
readonly privateKey: string;
|
||||
/**
|
||||
* whether allow user to signup with email directly
|
||||
*/
|
||||
enableSignup: boolean;
|
||||
/**
|
||||
* whether allow user to signup by oauth providers
|
||||
*/
|
||||
enableOauth: boolean;
|
||||
/**
|
||||
* NEXTAUTH_SECRET
|
||||
*/
|
||||
nextAuthSecret: string;
|
||||
/**
|
||||
* all available oauth providers
|
||||
*/
|
||||
oauthProviders: Partial<
|
||||
Record<
|
||||
ExternalAccount,
|
||||
{
|
||||
enabled: boolean;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
/**
|
||||
* uri to start oauth flow
|
||||
*/
|
||||
authorizationUri?: string;
|
||||
/**
|
||||
* uri to authenticate `access_token` when user is redirected back from oauth provider with `code`
|
||||
*/
|
||||
accessTokenUri?: string;
|
||||
/**
|
||||
* uri to get user info with authenticated `access_token`
|
||||
*/
|
||||
userInfoUri?: string;
|
||||
args?: Record<string, any>;
|
||||
}
|
||||
>
|
||||
>;
|
||||
accessToken: {
|
||||
/**
|
||||
* Application access token expiration time in seconds
|
||||
*
|
||||
* @default 7 days
|
||||
*/
|
||||
ttl: number;
|
||||
/**
|
||||
* Application refresh token expiration time in seconds
|
||||
*
|
||||
* @default 30 days
|
||||
*/
|
||||
refreshTokenTtl: number;
|
||||
};
|
||||
captcha: {
|
||||
/**
|
||||
* whether to enable captcha
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
||||
|
||||
import { merge } from 'lodash-es';
|
||||
import parse from 'parse-duration';
|
||||
|
||||
import pkg from '../../../package.json' assert { type: 'json' };
|
||||
import {
|
||||
@@ -23,7 +22,9 @@ AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
|
||||
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
|
||||
-----END EC PRIVATE KEY-----`;
|
||||
|
||||
const jwtKeyPair = (function () {
|
||||
const ONE_DAY_IN_SEC = 60 * 60 * 24;
|
||||
|
||||
const keyPair = (function () {
|
||||
const AUTH_PRIVATE_KEY = process.env.AUTH_PRIVATE_KEY ?? examplePrivateKey;
|
||||
const privateKey = createPrivateKey({
|
||||
key: Buffer.from(AUTH_PRIVATE_KEY),
|
||||
@@ -114,6 +115,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
get deploy() {
|
||||
return !this.node.dev && !this.node.test;
|
||||
},
|
||||
secrets: {
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
},
|
||||
featureFlags: {
|
||||
earlyAccessPreview: false,
|
||||
syncClientVersionCheck: false,
|
||||
@@ -145,11 +150,13 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
playground: true,
|
||||
},
|
||||
auth: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
accessTokenExpiresIn: parse('1h')! / 1000,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
refreshTokenExpiresIn: parse('7d')! / 1000,
|
||||
leeway: 60,
|
||||
session: {
|
||||
ttl: 15 * ONE_DAY_IN_SEC,
|
||||
},
|
||||
accessToken: {
|
||||
ttl: 7 * ONE_DAY_IN_SEC,
|
||||
refreshTokenTtl: 30 * ONE_DAY_IN_SEC,
|
||||
},
|
||||
captcha: {
|
||||
enable: false,
|
||||
turnstile: {
|
||||
@@ -159,14 +166,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
bits: 20,
|
||||
},
|
||||
},
|
||||
privateKey: jwtKeyPair.privateKey,
|
||||
publicKey: jwtKeyPair.publicKey,
|
||||
enableSignup: true,
|
||||
enableOauth: false,
|
||||
get nextAuthSecret() {
|
||||
return this.privateKey;
|
||||
},
|
||||
oauthProviders: {},
|
||||
},
|
||||
storage: getDefaultAFFiNEStorageConfig(),
|
||||
rateLimiter: {
|
||||
@@ -188,10 +187,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
enabled: false,
|
||||
},
|
||||
plugins: {
|
||||
enabled: [],
|
||||
enabled: new Set(),
|
||||
use(plugin, config) {
|
||||
this[plugin] = merge(this[plugin], config || {});
|
||||
this.enabled.push(plugin);
|
||||
this.enabled.add(plugin);
|
||||
},
|
||||
},
|
||||
} satisfies AFFiNEConfig;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { ConfigModule } from '../../config';
|
||||
import { CryptoHelper } from '../crypto';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
crypto: CryptoHelper;
|
||||
}>;
|
||||
|
||||
const key = `-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49
|
||||
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
|
||||
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
|
||||
-----END EC PRIVATE KEY-----`;
|
||||
const privateKey = createPrivateKey({
|
||||
key,
|
||||
format: 'pem',
|
||||
type: 'sec1',
|
||||
})
|
||||
.export({
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
})
|
||||
.toString('utf8');
|
||||
|
||||
const publicKey = createPublicKey({
|
||||
key,
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
.export({
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
.toString('utf8');
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
secrets: {
|
||||
publicKey,
|
||||
privateKey,
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [CryptoHelper],
|
||||
}).compile();
|
||||
|
||||
t.context.crypto = module.get(CryptoHelper);
|
||||
});
|
||||
|
||||
test('should be able to sign and verify', t => {
|
||||
const data = 'hello world';
|
||||
const signature = t.context.crypto.sign(data);
|
||||
t.true(t.context.crypto.verify(data, signature));
|
||||
t.false(t.context.crypto.verify(data, 'fake-signature'));
|
||||
});
|
||||
|
||||
test('should be able to encrypt and decrypt', t => {
|
||||
const data = 'top secret';
|
||||
const stub = Sinon.stub(t.context.crypto, 'randomBytes').returns(
|
||||
Buffer.alloc(12, 0)
|
||||
);
|
||||
|
||||
const encrypted = t.context.crypto.encrypt(data);
|
||||
const decrypted = t.context.crypto.decrypt(encrypted);
|
||||
|
||||
// we are using a stub to make sure the iv is always 0,
|
||||
// the encrypted result will always be the same
|
||||
t.is(encrypted, 'AAAAAAAAAAAAAAAAWUDlJRhzP+SZ3avvmLcgnou+q4E11w==');
|
||||
t.is(decrypted, data);
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('should be able to get random bytes', t => {
|
||||
const bytes = t.context.crypto.randomBytes();
|
||||
t.is(bytes.length, 12);
|
||||
const bytes2 = t.context.crypto.randomBytes();
|
||||
|
||||
t.notDeepEqual(bytes, bytes2);
|
||||
});
|
||||
|
||||
test('should be able to digest', t => {
|
||||
const data = 'hello world';
|
||||
const hash = t.context.crypto.sha256(data).toString('base64');
|
||||
t.is(hash, 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=');
|
||||
});
|
||||
|
||||
test('should be able to safe compare', t => {
|
||||
t.true(t.context.crypto.compare('abc', 'abc'));
|
||||
t.false(t.context.crypto.compare('abc', 'def'));
|
||||
});
|
||||
|
||||
test('should be able to hash and verify password', async t => {
|
||||
const password = 'mySecurePassword';
|
||||
const hash = await t.context.crypto.encryptPassword(password);
|
||||
t.true(await t.context.crypto.verifyPassword(password, hash));
|
||||
t.false(await t.context.crypto.verifyPassword('wrong-password', hash));
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { ConfigModule } from '../../config';
|
||||
import { URLHelper } from '../url';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
url: URLHelper;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
host: 'app.affine.local',
|
||||
port: 3010,
|
||||
https: true,
|
||||
}),
|
||||
],
|
||||
providers: [URLHelper],
|
||||
}).compile();
|
||||
|
||||
t.context.url = module.get(URLHelper);
|
||||
});
|
||||
|
||||
test('can get home page', t => {
|
||||
t.is(t.context.url.home, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can stringify query', t => {
|
||||
t.is(t.context.url.stringify({ a: 1, b: 2 }), 'a=1&b=2');
|
||||
t.is(t.context.url.stringify({ a: 1, b: '/path' }), 'a=1&b=%2Fpath');
|
||||
});
|
||||
|
||||
test('can create link', t => {
|
||||
t.is(t.context.url.link('/path'), 'https://app.affine.local/path');
|
||||
t.is(
|
||||
t.context.url.link('/path', { a: 1, b: 2 }),
|
||||
'https://app.affine.local/path?a=1&b=2'
|
||||
);
|
||||
t.is(
|
||||
t.context.url.link('/path', { a: 1, b: '/path' }),
|
||||
'https://app.affine.local/path?a=1&b=%2Fpath'
|
||||
);
|
||||
});
|
||||
|
||||
test('can safe redirect', t => {
|
||||
const res = {
|
||||
redirect: (to: string) => to,
|
||||
} as any;
|
||||
|
||||
const spy = Sinon.spy(res, 'redirect');
|
||||
function allow(to: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(to));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
function deny(to: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(t.context.url.home));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
[
|
||||
'https://app.affine.local',
|
||||
'https://app.affine.local/path',
|
||||
'https://app.affine.local/path?query=1',
|
||||
].forEach(allow);
|
||||
['https://other.domain.com', 'a://invalid.uri'].forEach(deny);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
createSign,
|
||||
createVerify,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
hash as hashPassword,
|
||||
verify as verifyPassword,
|
||||
} from '@node-rs/argon2';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
const NONCE_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 12;
|
||||
|
||||
@Injectable()
|
||||
export class CryptoHelper {
|
||||
keyPair: {
|
||||
publicKey: Buffer;
|
||||
privateKey: Buffer;
|
||||
sha256: {
|
||||
publicKey: Buffer;
|
||||
privateKey: Buffer;
|
||||
};
|
||||
};
|
||||
|
||||
constructor(config: Config) {
|
||||
this.keyPair = {
|
||||
publicKey: Buffer.from(config.secrets.publicKey, 'utf8'),
|
||||
privateKey: Buffer.from(config.secrets.privateKey, 'utf8'),
|
||||
sha256: {
|
||||
publicKey: this.sha256(config.secrets.publicKey),
|
||||
privateKey: this.sha256(config.secrets.privateKey),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
sign(data: string) {
|
||||
const sign = createSign('rsa-sha256');
|
||||
sign.update(data, 'utf-8');
|
||||
sign.end();
|
||||
return sign.sign(this.keyPair.privateKey, 'base64');
|
||||
}
|
||||
|
||||
verify(data: string, signature: string) {
|
||||
const verify = createVerify('rsa-sha256');
|
||||
verify.update(data, 'utf-8');
|
||||
verify.end();
|
||||
return verify.verify(this.keyPair.privateKey, signature, 'base64');
|
||||
}
|
||||
|
||||
encrypt(data: string) {
|
||||
const iv = this.randomBytes();
|
||||
const cipher = createCipheriv(
|
||||
'aes-256-gcm',
|
||||
this.keyPair.sha256.privateKey,
|
||||
iv,
|
||||
{
|
||||
authTagLength: AUTH_TAG_LENGTH,
|
||||
}
|
||||
);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(data, 'utf-8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
decrypt(encrypted: string) {
|
||||
const buf = Buffer.from(encrypted, 'base64');
|
||||
const iv = buf.subarray(0, NONCE_LENGTH);
|
||||
const authTag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
const encryptedToken = buf.subarray(NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
this.keyPair.sha256.privateKey,
|
||||
iv,
|
||||
{ authTagLength: AUTH_TAG_LENGTH }
|
||||
);
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrepted = decipher.update(encryptedToken, void 0, 'utf8');
|
||||
return decrepted + decipher.final('utf8');
|
||||
}
|
||||
|
||||
encryptPassword(password: string) {
|
||||
return hashPassword(password);
|
||||
}
|
||||
|
||||
verifyPassword(password: string, hash: string) {
|
||||
return verifyPassword(hash, password);
|
||||
}
|
||||
|
||||
compare(lhs: string, rhs: string) {
|
||||
if (lhs.length !== rhs.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(Buffer.from(lhs), Buffer.from(rhs));
|
||||
}
|
||||
|
||||
randomBytes(length = NONCE_LENGTH) {
|
||||
return randomBytes(length);
|
||||
}
|
||||
|
||||
sha256(data: string) {
|
||||
return createHash('sha256').update(data).digest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { CryptoHelper } from './crypto';
|
||||
import { URLHelper } from './url';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [URLHelper, CryptoHelper],
|
||||
exports: [URLHelper, CryptoHelper],
|
||||
})
|
||||
export class HelpersModule {}
|
||||
|
||||
export { CryptoHelper, URLHelper };
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type Response } from 'express';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
@Injectable()
|
||||
export class URLHelper {
|
||||
redirectAllowHosts: string[];
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
this.redirectAllowHosts = [this.config.baseUrl];
|
||||
}
|
||||
|
||||
get home() {
|
||||
return this.config.baseUrl;
|
||||
}
|
||||
|
||||
stringify(query: Record<string, any>) {
|
||||
return new URLSearchParams(query).toString();
|
||||
}
|
||||
|
||||
link(path: string, query: Record<string, any> = {}) {
|
||||
const url = new URL(
|
||||
this.config.baseUrl + (path.startsWith('/') ? path : '/' + path)
|
||||
);
|
||||
|
||||
for (const key in query) {
|
||||
url.searchParams.set(key, query[key]);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
safeRedirect(res: Response, to: string) {
|
||||
try {
|
||||
const finalTo = new URL(decodeURIComponent(to), this.config.baseUrl);
|
||||
|
||||
for (const host of this.redirectAllowHosts) {
|
||||
const hostURL = new URL(host);
|
||||
if (
|
||||
hostURL.origin === finalTo.origin &&
|
||||
finalTo.pathname.startsWith(hostURL.pathname)
|
||||
) {
|
||||
return res.redirect(finalTo.toString().replace(/\/$/, ''));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// just ignore invalid url
|
||||
}
|
||||
|
||||
// redirect to home if the url is invalid
|
||||
return res.redirect(this.home);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
} from './config';
|
||||
export * from './error';
|
||||
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
||||
export { CryptoHelper, URLHelper } from './helpers';
|
||||
export { MailService } from './mailer';
|
||||
export { CallCounter, CallTimer, metrics } from './metrics';
|
||||
export {
|
||||
@@ -21,7 +22,6 @@ export {
|
||||
GlobalExceptionFilter,
|
||||
OptionalModule,
|
||||
} from './nestjs';
|
||||
export { SessionService } from './session';
|
||||
export * from './storage';
|
||||
export { type StorageProvider, StorageProviderFactory } from './storage';
|
||||
export { AuthThrottlerGuard, CloudThrottlerGuard, Throttle } from './throttler';
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { URLHelper } from '../helpers';
|
||||
import { MAILER_SERVICE, type MailerService, type Options } from './mailer';
|
||||
import { emailTemplate } from './template';
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper,
|
||||
@Optional() @Inject(MAILER_SERVICE) private readonly mailer?: MailerService
|
||||
) {}
|
||||
|
||||
@@ -41,7 +43,7 @@ export class MailService {
|
||||
}
|
||||
) {
|
||||
// TODO: use callback url when need support desktop app
|
||||
const buttonUrl = `${this.config.origin}/invite/${inviteId}`;
|
||||
const buttonUrl = this.url.link(`/invite/${inviteId}`);
|
||||
const workspaceAvatar = invitationInfo.workspace.avatar;
|
||||
|
||||
const content = `<p style="margin:0">${
|
||||
@@ -92,7 +94,23 @@ export class MailService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendSignInEmail(url: string, options: Options) {
|
||||
async sendSignUpMail(url: string, options: Options) {
|
||||
const html = emailTemplate({
|
||||
title: 'Create AFFiNE Account',
|
||||
content:
|
||||
'Click the button below to complete your account creation and sign in. This magic link will expire in 30 minutes.',
|
||||
buttonContent: ' Create account and sign in',
|
||||
buttonUrl: url,
|
||||
});
|
||||
|
||||
return this.sendMail({
|
||||
html,
|
||||
subject: 'Your AFFiNE account is waiting for you!',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async sendSignInMail(url: string, options: Options) {
|
||||
const html = emailTemplate({
|
||||
title: 'Sign in to AFFiNE',
|
||||
content:
|
||||
@@ -164,6 +182,20 @@ export class MailService {
|
||||
html,
|
||||
});
|
||||
}
|
||||
async sendVerifyEmail(to: string, url: string) {
|
||||
const html = emailTemplate({
|
||||
title: 'Verify your email address',
|
||||
content:
|
||||
'You recently requested to verify the email address associated with your AFFiNE account. To complete this process, please click on the verification link below. This magic link will expire in 30 minutes.',
|
||||
buttonContent: 'Verify your email address',
|
||||
buttonUrl: url,
|
||||
});
|
||||
return this.sendMail({
|
||||
to,
|
||||
subject: `Verify your email for AFFiNE`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
async sendNotificationChangeEmail(to: string) {
|
||||
const html = emailTemplate({
|
||||
title: 'Email change successful',
|
||||
|
||||
@@ -9,7 +9,7 @@ import { omit } from 'lodash-es';
|
||||
|
||||
import { Config, ConfigPaths } from '../config';
|
||||
|
||||
interface OptionalModuleMetadata extends ModuleMetadata {
|
||||
export interface OptionalModuleMetadata extends ModuleMetadata {
|
||||
/**
|
||||
* Only install module if given config paths are defined in AFFiNE config.
|
||||
*/
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Global, Injectable, Module } from '@nestjs/common';
|
||||
|
||||
import { SessionCache } from '../cache';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private readonly prefix = 'session:';
|
||||
public readonly sessionTtl = 30 * 60 * 1000; // 30 min
|
||||
|
||||
constructor(private readonly cache: SessionCache) {}
|
||||
|
||||
/**
|
||||
* get session
|
||||
* @param key session key
|
||||
* @returns
|
||||
*/
|
||||
async get(key: string) {
|
||||
return this.cache.get<string>(this.prefix + key);
|
||||
}
|
||||
|
||||
/**
|
||||
* set session
|
||||
* @param key session key
|
||||
* @param value session value
|
||||
* @param sessionTtl session ttl (ms), default 30 min
|
||||
* @returns return true if success
|
||||
*/
|
||||
async set(key: string, value?: any, sessionTtl = this.sessionTtl) {
|
||||
return this.cache.set<string>(this.prefix + key, value, {
|
||||
ttl: sessionTtl,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(key: string) {
|
||||
return this.cache.delete(this.prefix + key);
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SessionService],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
@@ -1,53 +1,8 @@
|
||||
import type { ArgumentsHost, ExecutionContext } from '@nestjs/common';
|
||||
import type { GqlContextType } from '@nestjs/graphql';
|
||||
import { GqlArgumentsHost, GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { GqlArgumentsHost } from '@nestjs/graphql';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
export function getRequestResponseFromContext(context: ExecutionContext) {
|
||||
switch (context.getType<GqlContextType>()) {
|
||||
case 'graphql': {
|
||||
const gqlContext = GqlExecutionContext.create(context).getContext<{
|
||||
req: Request;
|
||||
}>();
|
||||
return {
|
||||
req: gqlContext.req,
|
||||
res: gqlContext.req.res,
|
||||
};
|
||||
}
|
||||
case 'http': {
|
||||
const http = context.switchToHttp();
|
||||
return {
|
||||
req: http.getRequest<Request>(),
|
||||
res: http.getResponse<Response>(),
|
||||
};
|
||||
}
|
||||
case 'ws': {
|
||||
const ws = context.switchToWs();
|
||||
const req = ws.getClient().handshake;
|
||||
|
||||
const cookies = req?.headers?.cookie;
|
||||
// patch cookies to match auth guard logic
|
||||
if (typeof cookies === 'string') {
|
||||
req.cookies = cookies
|
||||
.split(';')
|
||||
.map(v => v.split('='))
|
||||
.reduce(
|
||||
(acc, v) => {
|
||||
acc[decodeURIComponent(v[0].trim())] = decodeURIComponent(
|
||||
v[1].trim()
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
return { req };
|
||||
}
|
||||
default:
|
||||
throw new Error('Unknown context type for getting request and response');
|
||||
}
|
||||
}
|
||||
import type { Socket } from 'socket.io';
|
||||
|
||||
export function getRequestResponseFromHost(host: ArgumentsHost) {
|
||||
switch (host.getType<GqlContextType>()) {
|
||||
@@ -67,11 +22,47 @@ export function getRequestResponseFromHost(host: ArgumentsHost) {
|
||||
res: http.getResponse<Response>(),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error('Unknown host type for getting request and response');
|
||||
case 'ws': {
|
||||
const ws = host.switchToWs();
|
||||
const req = ws.getClient<Socket>().client.conn.request as Request;
|
||||
|
||||
const cookieStr = req?.headers?.cookie;
|
||||
// patch cookies to match auth guard logic
|
||||
if (typeof cookieStr === 'string') {
|
||||
req.cookies = cookieStr.split(';').reduce(
|
||||
(cookies, cookie) => {
|
||||
const [key, val] = cookie.split('=');
|
||||
|
||||
if (key) {
|
||||
cookies[decodeURIComponent(key.trim())] = val
|
||||
? decodeURIComponent(val.trim())
|
||||
: val;
|
||||
}
|
||||
|
||||
return cookies;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
return { req };
|
||||
}
|
||||
case 'rpc': {
|
||||
const rpc = host.switchToRpc();
|
||||
const { req } = rpc.getContext<{ req: Request }>();
|
||||
|
||||
return {
|
||||
req,
|
||||
res: req.res,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequestFromHost(host: ArgumentsHost) {
|
||||
return getRequestResponseFromHost(host).req;
|
||||
}
|
||||
|
||||
export function getRequestResponseFromContext(ctx: ExecutionContext) {
|
||||
return getRequestResponseFromHost(ctx);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user