totp deactivate, delete tokens on oauth deauth

This commit is contained in:
Evert Prants 2022-08-28 16:17:15 +03:00
parent 7a06c882ac
commit d92b8ab828
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
17 changed files with 220 additions and 48 deletions

77
package-lock.json generated
View File

@ -14,6 +14,7 @@
"@nestjs/core": "^9.0.11",
"@nestjs/platform-express": "^9.0.11",
"@nestjs/serve-static": "^3.0.0",
"@nestjs/swagger": "^6.1.0",
"@nestjs/throttler": "^3.0.0",
"bcrypt": "^5.0.1",
"class-transformer": "^0.5.1",
@ -2898,6 +2899,25 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
},
"node_modules/@nestjs/mapped-types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.1.0.tgz",
"integrity": "sha512-+2kSly4P1QI+9eGt+/uGyPdEG1hVz7nbpqPHWZVYgoqz8eOHljpXPag+UCVRw9zo2XCu4sgNUIGe8Uk0+OvUQg==",
"peerDependencies": {
"@nestjs/common": "^7.0.8 || ^8.0.0 || ^9.0.0",
"class-transformer": "^0.2.0 || ^0.3.0 || ^0.4.0 || ^0.5.0",
"class-validator": "^0.11.1 || ^0.12.0 || ^0.13.0",
"reflect-metadata": "^0.1.12"
},
"peerDependenciesMeta": {
"class-transformer": {
"optional": true
},
"class-validator": {
"optional": true
}
}
},
"node_modules/@nestjs/platform-express": {
"version": "9.0.11",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-9.0.11.tgz",
@ -2992,6 +3012,29 @@
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.2.5.tgz",
"integrity": "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q=="
},
"node_modules/@nestjs/swagger": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-6.1.0.tgz",
"integrity": "sha512-Lflplv216nXkH6By/jMggQjpuH1V67M07tgHXUAZujAwG3LAJ1CfSvzuFckK4MAb54xQTYvFgfVPWkXqvKXzdA==",
"dependencies": {
"@nestjs/mapped-types": "1.1.0",
"js-yaml": "4.1.0",
"lodash": "4.17.21",
"path-to-regexp": "3.2.0",
"swagger-ui-dist": "4.14.0"
},
"peerDependencies": {
"@fastify/static": "^6.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/core": "^9.0.0",
"reflect-metadata": "^0.1.12"
},
"peerDependenciesMeta": {
"@fastify/static": {
"optional": true
}
}
},
"node_modules/@nestjs/testing": {
"version": "9.0.11",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.0.11.tgz",
@ -8685,8 +8728,7 @@
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
@ -11018,6 +11060,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swagger-ui-dist": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.14.0.tgz",
"integrity": "sha512-TBzhheU15s+o54Cgk9qxuYcZMiqSm/SkvKnapoGHOF66kz0Y5aGjpzj5BT/vpBbn6rTPJ9tUYXQxuDWfsjiGMw=="
},
"node_modules/symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
@ -14526,6 +14573,12 @@
}
}
},
"@nestjs/mapped-types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.1.0.tgz",
"integrity": "sha512-+2kSly4P1QI+9eGt+/uGyPdEG1hVz7nbpqPHWZVYgoqz8eOHljpXPag+UCVRw9zo2XCu4sgNUIGe8Uk0+OvUQg==",
"requires": {}
},
"@nestjs/platform-express": {
"version": "9.0.11",
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-9.0.11.tgz",
@ -14600,6 +14653,18 @@
}
}
},
"@nestjs/swagger": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-6.1.0.tgz",
"integrity": "sha512-Lflplv216nXkH6By/jMggQjpuH1V67M07tgHXUAZujAwG3LAJ1CfSvzuFckK4MAb54xQTYvFgfVPWkXqvKXzdA==",
"requires": {
"@nestjs/mapped-types": "1.1.0",
"js-yaml": "4.1.0",
"lodash": "4.17.21",
"path-to-regexp": "3.2.0",
"swagger-ui-dist": "4.14.0"
}
},
"@nestjs/testing": {
"version": "9.0.11",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-9.0.11.tgz",
@ -18979,8 +19044,7 @@
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.debounce": {
"version": "4.0.8",
@ -20739,6 +20803,11 @@
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="
},
"swagger-ui-dist": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.14.0.tgz",
"integrity": "sha512-TBzhheU15s+o54Cgk9qxuYcZMiqSm/SkvKnapoGHOF66kz0Y5aGjpzj5BT/vpBbn6rTPJ9tUYXQxuDWfsjiGMw=="
},
"symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",

View File

@ -29,6 +29,7 @@
"@nestjs/core": "^9.0.11",
"@nestjs/platform-express": "^9.0.11",
"@nestjs/serve-static": "^3.0.0",
"@nestjs/swagger": "^6.1.0",
"@nestjs/throttler": "^3.0.0",
"bcrypt": "^5.0.1",
"class-transformer": "^0.5.1",

View File

@ -6,13 +6,28 @@ import * as connectRedis from 'connect-redis';
import * as redis from 'redis';
import * as cookieParser from 'cookie-parser';
import { join } from 'path';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AdminApiModule } from './modules/api/admin/admin.module';
import { OAuth2RouterModule } from './modules/static-front-end/oauth2-router/oauth2-router.module';
dotenv.config();
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const config = new DocumentBuilder()
.setTitle('Icy Network Authentication Server')
.setDescription('Central authentication and management server')
.setVersion('1.0')
.addTag('admin')
.addTag('oauth2')
.build();
const document = SwaggerModule.createDocument(app, config, {
include: [AdminApiModule, OAuth2RouterModule],
});
SwaggerModule.setup('api', app, document);
const RedisStore = connectRedis(session);
const redisClient = redis.createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',

View File

@ -9,10 +9,7 @@ import { NextFunction, Request, Response } from 'express';
export class AuthMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
if (!req.session.user) {
if (
req.header('content-type')?.includes('application/json') ||
req.header('accept')?.includes('application/json')
) {
if (req.header('content-type')?.includes('application/json')) {
throw new UnauthorizedException('Unauthorized');
}

View File

@ -15,6 +15,7 @@ import {
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { unlink } from 'fs/promises';
import { Privileges } from 'src/decorators/privileges.decorator';
@ -53,6 +54,8 @@ const URL_TYPES = ['redirect_uri', 'terms', 'privacy', 'website'];
const REQUIRED_CLIENT_FIELDS = ['title', 'scope', 'grants', 'activated'];
@ApiBearerAuth()
@ApiTags('admin')
@Controller('/api/admin/oauth2')
@UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard)
export class OAuth2AdminController {

View File

@ -6,6 +6,7 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Privileges } from 'src/decorators/privileges.decorator';
import { Scopes } from 'src/decorators/scopes.decorator';
import { OAuth2Guard } from 'src/guards/oauth2.guard';
@ -13,6 +14,8 @@ import { PrivilegesGuard } from 'src/guards/privileges.guard';
import { ScopesGuard } from 'src/guards/scopes.guard';
import { PrivilegeService } from 'src/modules/objects/privilege/privilege.service';
@ApiBearerAuth()
@ApiTags('admin')
@Controller('/api/admin/privileges')
@UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard)
export class PrivilegeAdminController {

View File

@ -11,6 +11,7 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Privileges } from 'src/decorators/privileges.decorator';
import { Scopes } from 'src/decorators/scopes.decorator';
import { OAuth2Guard } from 'src/guards/oauth2.guard';
@ -24,6 +25,8 @@ import { PageOptions } from 'src/types/pagination.interfaces';
const RELATIONS = ['picture', 'privileges'];
@ApiBearerAuth()
@ApiTags('admin')
@Controller('/api/admin/users')
@UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard)
export class UserAdminController {

View File

@ -12,7 +12,6 @@ import { RefreshTokenAdapter } from './adapter/refresh-token.adapter';
import { UserAdapter } from './adapter/user.adapter';
const SCOPE_DESCRIPTION: Record<string, string> = {
management: 'Manage Icy Network on your behalf',
email: 'Email address',
image: 'Profile picture',
};
@ -34,7 +33,7 @@ export class OAuth2Service {
this._oauthAdapter,
async (req, res, client, scope) => {
const fullClient = await this.clientService.getById(client.id as string);
const allowedScopes = [...ALWAYS_AVAILABLE];
let allowedScopes = [...ALWAYS_AVAILABLE];
let disallowedScopes = [...ALWAYS_UNAVAILABLE];
Object.keys(SCOPE_DESCRIPTION).forEach((item) => {
@ -46,10 +45,11 @@ export class OAuth2Service {
});
if (scope.includes('management')) {
disallowedScopes = [
'THIS APPLICATION COULD ACCESS SENSITIVE INFORMATION!',
'MAKE SURE YOU TRUST THE DEVELOPERS OF THIS APPLICATION',
allowedScopes = [
'Manage Icy Network on your behalf',
'Commit administrative actions to the extent of your user privileges',
];
disallowedScopes = null;
}
res.render('authorize', {

View File

@ -118,7 +118,7 @@ export class OAuth2ClientService {
user: { id: user.id },
id: authId,
},
relations: ['user'],
relations: ['user', 'client'],
});
}

View File

@ -64,9 +64,13 @@ export class OAuth2TokenService {
});
}
public async wipeClientTokens(client: OAuth2Client): Promise<void> {
public async wipeClientTokens(
client: OAuth2Client,
user?: User,
): Promise<void> {
await this.tokenRepository.delete({
client: { id: client.id },
user: user ? { id: user.id } : undefined,
});
}

View File

@ -34,6 +34,7 @@ export class UserTOTPService {
public async getUserTOTP(user: User): Promise<UserToken> {
return this.userTokenRepository.findOne({
where: { user: { id: user.id }, type: UserTokenType.TOTP },
relations: ['user'],
});
}
@ -68,4 +69,16 @@ export class UserTOTPService {
return [totp, recovery];
}
public async deactivateTOTP(token: UserToken): Promise<void> {
if (!token) {
return;
}
await this.userTokenRepository.delete({
type: UserTokenType.RECOVERY,
user: { id: token.user.id },
});
await this.userTokenRepository.remove(token);
}
}

View File

@ -8,6 +8,7 @@ import {
Res,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { NextFunction, Request, Response } from 'express';
import { Scope } from 'src/decorators/scope.decorator';
import { CurrentUser } from 'src/decorators/user.decorator';
@ -16,6 +17,7 @@ import { ConfigurationService } from 'src/modules/config/config.service';
import { User } from 'src/modules/objects/user/user.entity';
import { OAuth2Service } from '../../oauth2/oauth2.service';
@ApiTags('oauth2')
@Controller('oauth2')
export class OAuth2Controller {
constructor(
@ -52,6 +54,7 @@ export class OAuth2Controller {
return this._service.oauth.controller.token(req, res, next);
}
@ApiBearerAuth()
@Post('introspect')
public introspectWrapper(
@Req() req: Request,
@ -64,6 +67,7 @@ export class OAuth2Controller {
// User information endpoint
// TODO: Move to API
@ApiBearerAuth()
@Get('user')
@UseGuards(OAuth2Guard)
public async userInfo(

View File

@ -19,6 +19,7 @@ import { Throttle } from '@nestjs/throttler';
import { Request, Response } from 'express';
import { unlink } from 'fs/promises';
import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service';
import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service';
import { UploadService } from 'src/modules/objects/upload/upload.service';
import { UserTOTPService } from 'src/modules/objects/user-token/user-totp-token.service';
import { UserService } from 'src/modules/objects/user/user.service';
@ -36,6 +37,7 @@ export class SettingsController {
private readonly _user: UserService,
private readonly _totp: UserTOTPService,
private readonly _client: OAuth2ClientService,
private readonly _oaToken: OAuth2TokenService,
) {}
@Get()
@ -162,6 +164,7 @@ export class SettingsController {
return;
}
await this._oaToken.wipeClientTokens(getAuth.client, req.user);
await this._client.revokeAuthorization(getAuth);
if (jsreq) {

View File

@ -15,11 +15,12 @@ import { ConfigurationModule } from 'src/modules/config/config.module';
import { ConfigurationService } from 'src/modules/config/config.service';
import { UploadModule } from 'src/modules/objects/upload/upload.module';
import { UserModule } from 'src/modules/objects/user/user.module';
import { OAuth2TokenModule } from 'src/modules/objects/oauth2-token/oauth2-token.module';
import { OAuth2ClientModule } from 'src/modules/objects/oauth2-client/oauth2-client.module';
import { UserTokenModule } from 'src/modules/objects/user-token/user-token.module';
import { OAuth2Module } from '../../oauth2/oauth2.module';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
import { OAuth2ClientModule } from 'src/modules/objects/oauth2-client/oauth2-client.module';
import { UserTokenModule } from 'src/modules/objects/user-token/user-token.module';
@Module({
controllers: [SettingsController],
@ -30,6 +31,7 @@ import { UserTokenModule } from 'src/modules/objects/user-token/user-token.modul
UserTokenModule,
OAuth2Module,
OAuth2ClientModule,
OAuth2TokenModule,
MulterModule.registerAsync({
imports: [ConfigurationModule],
useFactory: async (config: ConfigurationService) => {

View File

@ -1,7 +1,7 @@
import { Body, Controller, Get, Post, Req, Res, Session } from '@nestjs/common';
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { SessionData } from 'express-session';
import { UserTOTPService } from 'src/modules/objects/user-token/user-totp-token.service';
import { UserService } from 'src/modules/objects/user/user.service';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { QRCodeService } from 'src/modules/utility/services/qr-code.service';
import { TokenService } from 'src/modules/utility/services/token.service';
@ -12,30 +12,31 @@ export class TwoFactorController {
private totp: UserTOTPService,
private qr: QRCodeService,
private token: TokenService,
private user: UserService,
private form: FormUtilityService,
) {}
@Get('activate')
public async twoFAStatus(
@Session() session: SessionData,
@Req() req: Request,
@Res() res: Response,
) {
public async twoFAStatus(@Req() req: Request, @Res() res: Response) {
const twoFA = await this.totp.getUserTOTP(req.user);
let secret: string;
if (!twoFA) {
if (session.challenge) {
const challenge = await this.token.decryptChallenge(session.challenge);
if (challenge.type === 'totp') {
const challengeString = req.query.challenge as string;
if (challengeString) {
const challenge = await this.token.decryptChallenge(challengeString);
if (challenge.type === 'totp' && challenge.user === req.user.uuid) {
secret = challenge.secret;
}
}
if (!secret) {
secret = this.totp.createTOTPSecret();
const challenge = { type: 'totp', secret };
session.challenge = await this.token.encryptChallenge(challenge);
const challenge = { type: 'totp', secret, user: req.user.uuid };
const encrypted = await this.token.encryptChallenge(challenge);
const cleanURL = req.originalUrl.replace(/\?(.*)$/, '');
res.redirect(`${cleanURL}?challenge=${encrypted}`);
return;
}
const url = this.totp.getTOTPURL(secret, req.user.username);
@ -54,21 +55,25 @@ export class TwoFactorController {
@Post('activate')
public async twoFAActivate(
@Session() session: SessionData,
@Body() body: { code: string },
@Req() req: Request,
@Res() res: Response,
) {
let secret: string;
try {
if (!session.challenge || !body.code) {
const challengeString = req.query.challenge as string;
if (!challengeString || !body.code) {
throw new Error('Invalid request');
}
const challenge = await this.token.decryptChallenge(session.challenge);
const challenge = await this.token.decryptChallenge(challengeString);
secret = challenge.secret;
if (challenge.type !== 'totp' || !secret) {
if (
challenge.type !== 'totp' ||
challenge.user !== req.user.uuid ||
!secret
) {
throw new Error('Invalid request');
}
@ -87,7 +92,59 @@ export class TwoFactorController {
// TODO: show the recovery tokens to the user
await this.totp.activateTOTP(req.user, secret);
session.challenge = null;
req.flash('message', {
error: false,
text: 'Two-factor authenticator has been enabled successfully. Your account is now more secure!',
});
res.redirect('/');
}
@Get('disable')
public async disableTwoFA(@Req() req: Request, @Res() res: Response) {
const twoFA = await this.totp.getUserTOTP(req.user);
if (!twoFA) {
return res.redirect('/');
}
res.render('password', this.form.populateTemplate(req));
}
@Post('disable')
public async disableTwoFAPost(
@Req() req: Request,
@Res() res: Response,
@Body() body: { password: string },
) {
const twoFA = await this.totp.getUserTOTP(req.user);
if (!twoFA) {
return res.redirect('/');
}
try {
if (!body.password) {
throw new Error('Please enter your password');
}
if (
!(await this.user.comparePasswords(req.user.password, body.password))
) {
throw new Error('The entered password is invalid.');
}
await this.totp.deactivateTOTP(twoFA);
} catch (e: any) {
req.flash('message', {
error: true,
text: e.message,
});
res.redirect('/account/two-factor/disable');
return;
}
req.flash('message', {
error: false,
text: 'Two-factor authenticator has been disabled successfully. Your account is now less secure!!',
});
res.redirect('/');
}
}

View File

@ -38,18 +38,19 @@ block body
if url.type == 'terms'
a.authorize__client-url(href=url.url, target="_blank", rel="nofollow") Terms of Service
h2.text-center This application will have access to..
if allowedScopes
h2.text-center This application will have access to..
.scopes
each allowed in allowedScopes
span.scopes__scope.scopes__scope--allowed #{allowed}
.scopes
each allowed in allowedScopes
span.scopes__scope.scopes__scope--allowed #{allowed}
if disallowedScopes
h2.text-center This application will not have access to..
h2.text-center This application will not have access to..
.scopes
each allowed in disallowedScopes
span.scopes__scope.scopes__scope--disallowed #{allowed}
.scopes
each allowed in disallowedScopes
span.scopes__scope.scopes__scope--disallowed #{allowed}
form(method="POST", action="")
div.form-container

View File

@ -42,9 +42,6 @@ block settings
p Two-factor authentication is enabled.
a.btn.btn-primary(href="/account/two-factor/disable") Disable
else
p You can enable two-factor authentication using an authenticator app of your choice, such as
b Google Authenticator
| or
b andOTP
|.
p You can enable two-factor authentication using an authenticator app of your choice, such as Google Authenticator.
| By clicking activate you will be prompted with a QR code which you will need to scan with such app.
a.btn.btn-primary(href="/account/two-factor/activate") Activate