fix oauth
This commit is contained in:
@@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs';
|
|||||||
import { CoreGrpcService } from '../core-grpc.service';
|
import { CoreGrpcService } from '../core-grpc.service';
|
||||||
import { OAuthAuthorizeIncomingDto, OAuthTokenIncomingDto } from '../dto/oauth.dto';
|
import { OAuthAuthorizeIncomingDto, OAuthTokenIncomingDto } from '../dto/oauth.dto';
|
||||||
import { extractBearerToken } from '../auth-token';
|
import { extractBearerToken } from '../auth-token';
|
||||||
import { appendQueryParams, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeTokenBody } from '../lib/oauth-params';
|
import { appendQueryParams, mapUserInfoToOidc, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeTokenBody } from '../lib/oauth-params';
|
||||||
import { resolveFrontendUrl } from '../lib/oauth-issuer';
|
import { resolveFrontendUrl } from '../lib/oauth-issuer';
|
||||||
import { verifyAccessToken } from '../session-auth';
|
import { verifyAccessToken } from '../session-auth';
|
||||||
|
|
||||||
@@ -80,7 +80,8 @@ export class OAuthController {
|
|||||||
clientId: normalized.clientId,
|
clientId: normalized.clientId,
|
||||||
redirectUri: normalized.redirectUri,
|
redirectUri: normalized.redirectUri,
|
||||||
scope: normalized.scope,
|
scope: normalized.scope,
|
||||||
state: normalized.state
|
state: normalized.state,
|
||||||
|
nonce: normalized.nonce
|
||||||
})
|
})
|
||||||
)) as { redirectUrl?: string; code?: string; state?: string };
|
)) as { redirectUrl?: string; code?: string; state?: string };
|
||||||
|
|
||||||
@@ -133,7 +134,20 @@ export class OAuthController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' })
|
@ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' })
|
||||||
@ApiResponse({ status: 200, description: 'Профиль пользователя получен' })
|
@ApiResponse({ status: 200, description: 'Профиль пользователя получен' })
|
||||||
userInfo(@Headers('authorization') authorization?: string) {
|
async userInfo(@Headers('authorization') authorization?: string) {
|
||||||
return this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) });
|
const result = (await firstValueFrom(
|
||||||
|
this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) })
|
||||||
|
)) as {
|
||||||
|
sub?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
preferredUsername?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
phoneNumberVerified?: boolean;
|
||||||
|
};
|
||||||
|
return mapUserInfoToOidc(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,21 @@ export function buildOpenIdConfiguration(issuer: string) {
|
|||||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
||||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||||
code_challenge_methods_supported: ['S256', 'plain'],
|
code_challenge_methods_supported: ['S256', 'plain'],
|
||||||
claims_supported: ['sub', 'email', 'phone', 'name', 'picture']
|
claims_supported: [
|
||||||
|
'sub',
|
||||||
|
'iss',
|
||||||
|
'aud',
|
||||||
|
'iat',
|
||||||
|
'exp',
|
||||||
|
'auth_time',
|
||||||
|
'nonce',
|
||||||
|
'email',
|
||||||
|
'email_verified',
|
||||||
|
'phone_number',
|
||||||
|
'phone_number_verified',
|
||||||
|
'name',
|
||||||
|
'preferred_username',
|
||||||
|
'picture'
|
||||||
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,33 @@ export function mapTokenResponseToStandard(result: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapUserInfoToOidc(result: {
|
||||||
|
sub?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
preferredUsername?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
phoneNumberVerified?: boolean;
|
||||||
|
}) {
|
||||||
|
const claims: Record<string, unknown> = { sub: result.sub };
|
||||||
|
if (result.name) claims.name = result.name;
|
||||||
|
if (result.picture) claims.picture = result.picture;
|
||||||
|
if (result.preferredUsername) claims.preferred_username = result.preferredUsername;
|
||||||
|
if (result.email) {
|
||||||
|
claims.email = result.email;
|
||||||
|
if (result.emailVerified !== undefined) claims.email_verified = result.emailVerified;
|
||||||
|
}
|
||||||
|
const phoneNumber = result.phoneNumber ?? result.phone;
|
||||||
|
if (phoneNumber) {
|
||||||
|
claims.phone_number = phoneNumber;
|
||||||
|
if (result.phoneNumberVerified !== undefined) claims.phone_number_verified = result.phoneNumberVerified;
|
||||||
|
}
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeTokenCredentials(
|
export function mergeTokenCredentials(
|
||||||
body: NormalizedTokenBody,
|
body: NormalizedTokenBody,
|
||||||
authorization?: string
|
authorization?: string
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ model OAuthAuthorizationCode {
|
|||||||
clientId String
|
clientId String
|
||||||
redirectUri String
|
redirectUri String
|
||||||
scopes String[]
|
scopes String[]
|
||||||
|
nonce String?
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
consumedAt DateTime?
|
consumedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -773,7 +773,14 @@ export class AuthGrpcController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GrpcMethod('OAuthCoreService', 'Authorize')
|
@GrpcMethod('OAuthCoreService', 'Authorize')
|
||||||
authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) {
|
authorize(command: {
|
||||||
|
userId: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
scope: string;
|
||||||
|
state?: string;
|
||||||
|
nonce?: string;
|
||||||
|
}) {
|
||||||
return this.oauthCore.authorize(command);
|
return this.oauthCore.authorize(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,28 @@ import { PrismaService } from '../infra/prisma.service';
|
|||||||
import { OAuthIssuerService } from './oauth-issuer.service';
|
import { OAuthIssuerService } from './oauth-issuer.service';
|
||||||
import { assertHumanAccount, HUMAN_USER_WHERE } from './system-account.util';
|
import { assertHumanAccount, HUMAN_USER_WHERE } from './system-account.util';
|
||||||
|
|
||||||
|
type OAuthUser = {
|
||||||
|
id: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
displayName: string;
|
||||||
|
username: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
isVerified: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OidcScopeClaims = {
|
||||||
|
sub: string;
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
preferredUsername?: string;
|
||||||
|
email?: string;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
phone?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
phoneNumberVerified?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OAuthCoreService {
|
export class OAuthCoreService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -15,7 +37,14 @@ export class OAuthCoreService {
|
|||||||
private readonly oauthIssuer: OAuthIssuerService
|
private readonly oauthIssuer: OAuthIssuerService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) {
|
async authorize(command: {
|
||||||
|
userId: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
scope: string;
|
||||||
|
state?: string;
|
||||||
|
nonce?: string;
|
||||||
|
}) {
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: { id: command.userId, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE }
|
where: { id: command.userId, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE }
|
||||||
});
|
});
|
||||||
@@ -37,6 +66,7 @@ export class OAuthCoreService {
|
|||||||
clientId: client.id,
|
clientId: client.id,
|
||||||
redirectUri: command.redirectUri,
|
redirectUri: command.redirectUri,
|
||||||
scopes: command.scope.split(' ').filter(Boolean),
|
scopes: command.scope.split(' ').filter(Boolean),
|
||||||
|
nonce: command.nonce ?? null,
|
||||||
expiresAt: new Date(Date.now() + 5 * 60_000)
|
expiresAt: new Date(Date.now() + 5 * 60_000)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -47,26 +77,50 @@ export class OAuthCoreService {
|
|||||||
return { redirectUrl: url.toString(), code, state: command.state };
|
return { redirectUrl: url.toString(), code, state: command.state };
|
||||||
}
|
}
|
||||||
|
|
||||||
async token(command: { grantType: string; code?: string; refreshToken?: string; clientId: string; clientSecret?: string; redirectUri?: string }) {
|
async token(command: {
|
||||||
|
grantType: string;
|
||||||
|
code?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
redirectUri?: string;
|
||||||
|
}) {
|
||||||
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } });
|
const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } });
|
||||||
if (!client?.isActive) throw new UnauthorizedException('OAuth приложение не найдено');
|
if (!client?.isActive) throw new UnauthorizedException('OAuth приложение не найдено');
|
||||||
if (client.clientSecret && client.clientSecret !== command.clientSecret) throw new UnauthorizedException('Неверный client_secret');
|
if (client.clientSecret && client.clientSecret !== command.clientSecret) {
|
||||||
|
throw new UnauthorizedException('Неверный client_secret');
|
||||||
|
}
|
||||||
|
|
||||||
if (command.grantType === 'authorization_code') {
|
if (command.grantType === 'authorization_code') {
|
||||||
if (!command.code || !command.redirectUri) throw new BadRequestException('Передайте code и redirect_uri');
|
if (!command.code || !command.redirectUri) throw new BadRequestException('Передайте code и redirect_uri');
|
||||||
const authCode = await this.prisma.oAuthAuthorizationCode.findUnique({ where: { code: command.code }, include: { user: true } });
|
const authCode = await this.prisma.oAuthAuthorizationCode.findUnique({
|
||||||
if (!authCode || authCode.consumedAt || authCode.expiresAt < new Date() || authCode.redirectUri !== command.redirectUri || authCode.clientId !== client.id) {
|
where: { code: command.code },
|
||||||
|
include: { user: true }
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!authCode ||
|
||||||
|
authCode.consumedAt ||
|
||||||
|
authCode.expiresAt < new Date() ||
|
||||||
|
authCode.redirectUri !== command.redirectUri ||
|
||||||
|
authCode.clientId !== client.id
|
||||||
|
) {
|
||||||
throw new UnauthorizedException('Код авторизации недействителен');
|
throw new UnauthorizedException('Код авторизации недействителен');
|
||||||
}
|
}
|
||||||
await this.prisma.oAuthAuthorizationCode.update({ where: { id: authCode.id }, data: { consumedAt: new Date() } });
|
await this.prisma.oAuthAuthorizationCode.update({ where: { id: authCode.id }, data: { consumedAt: new Date() } });
|
||||||
assertHumanAccount(authCode.user, { asAuth: true });
|
assertHumanAccount(authCode.user, { asAuth: true });
|
||||||
return this.issueOAuthTokens(authCode.user.id, client.clientId, authCode.scopes);
|
return this.issueOAuthTokens(authCode.user.id, client.clientId, authCode.scopes, authCode.nonce ?? undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command.grantType === 'refresh_token') {
|
if (command.grantType === 'refresh_token') {
|
||||||
if (!command.refreshToken) throw new BadRequestException('Передайте refresh_token');
|
if (!command.refreshToken) throw new BadRequestException('Передайте refresh_token');
|
||||||
const payload = await this.jwt.verifyAsync<{ sub: string; aud: string; scopes: string[]; typ: string }>(command.refreshToken, { secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET') });
|
const issuer = await this.oauthIssuer.resolveIssuer();
|
||||||
if (payload.typ !== 'refresh_token' || payload.aud !== client.clientId) throw new UnauthorizedException('Refresh token недействителен');
|
const payload = await this.jwt.verifyAsync<{ sub: string; aud: string; scopes: string[]; typ: string }>(
|
||||||
|
command.refreshToken,
|
||||||
|
{ secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'), issuer }
|
||||||
|
);
|
||||||
|
if (payload.typ !== 'refresh_token' || payload.aud !== client.clientId) {
|
||||||
|
throw new UnauthorizedException('Refresh token недействителен');
|
||||||
|
}
|
||||||
return this.issueOAuthTokens(payload.sub, client.clientId, payload.scopes ?? []);
|
return this.issueOAuthTokens(payload.sub, client.clientId, payload.scopes ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,21 +128,106 @@ export class OAuthCoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async userInfo(accessToken: string) {
|
async userInfo(accessToken: string) {
|
||||||
const payload = await this.jwt.verifyAsync<{ sub: string }>(accessToken, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET') });
|
const issuer = await this.oauthIssuer.resolveIssuer();
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: payload.sub } });
|
let payload: { sub: string; scopes?: string[]; typ?: string };
|
||||||
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
try {
|
||||||
assertHumanAccount(user, { asAuth: true });
|
payload = await this.jwt.verifyAsync(accessToken, {
|
||||||
return { sub: user.id, email: user.email, phone: user.phone, name: user.displayName, picture: user.avatarUrl };
|
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||||
|
issuer
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Токен доступа недействителен');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async issueOAuthTokens(userId: string, clientId: string, scopes: string[]) {
|
if (payload.typ && payload.typ !== 'access_token') {
|
||||||
|
throw new UnauthorizedException('Неверный тип токена');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findFirst({
|
||||||
|
where: { id: payload.sub, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE }
|
||||||
|
});
|
||||||
|
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
||||||
|
assertHumanAccount(user, { asAuth: true });
|
||||||
|
|
||||||
|
const scopes = this.parseScopes(payload.scopes ?? ['openid', 'profile']);
|
||||||
|
return this.buildUserClaims(user, scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseScopes(scopes: string[] | undefined) {
|
||||||
|
return new Set((scopes ?? []).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildUserClaims(user: OAuthUser, scopes: Set<string>): OidcScopeClaims {
|
||||||
|
const claims: OidcScopeClaims = { sub: user.id };
|
||||||
|
|
||||||
|
if (scopes.has('profile')) {
|
||||||
|
claims.name = user.displayName;
|
||||||
|
claims.preferredUsername = user.username ?? user.displayName;
|
||||||
|
if (user.avatarUrl) {
|
||||||
|
claims.picture = user.avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopes.has('email') && user.email) {
|
||||||
|
claims.email = user.email;
|
||||||
|
claims.emailVerified = user.isVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopes.has('phone') && user.phone) {
|
||||||
|
claims.phone = user.phone;
|
||||||
|
claims.phoneNumber = user.phone;
|
||||||
|
claims.phoneNumberVerified = user.isVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueOAuthTokens(userId: string, clientId: string, scopes: string[], nonce?: string) {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
||||||
assertHumanAccount(user, { asAuth: true });
|
assertHumanAccount(user, { asAuth: true });
|
||||||
|
|
||||||
const issuer = await this.oauthIssuer.resolveIssuer();
|
const issuer = await this.oauthIssuer.resolveIssuer();
|
||||||
const accessToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'access_token' }, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer });
|
const scopeSet = this.parseScopes(scopes);
|
||||||
const refreshToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'refresh_token' }, { secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer });
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const idToken = await this.jwt.signAsync({ sub: userId, aud: clientId }, { secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer });
|
const profileClaims = this.buildUserClaims(user, scopeSet);
|
||||||
|
|
||||||
|
const accessToken = await this.jwt.signAsync(
|
||||||
|
{ sub: userId, aud: clientId, scopes, typ: 'access_token' },
|
||||||
|
{ secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer }
|
||||||
|
);
|
||||||
|
const refreshToken = await this.jwt.signAsync(
|
||||||
|
{ sub: userId, aud: clientId, scopes, typ: 'refresh_token' },
|
||||||
|
{ secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer }
|
||||||
|
);
|
||||||
|
|
||||||
|
const idTokenClaims: Record<string, unknown> = {
|
||||||
|
sub: userId,
|
||||||
|
aud: clientId,
|
||||||
|
iat: now,
|
||||||
|
auth_time: now
|
||||||
|
};
|
||||||
|
if (nonce) {
|
||||||
|
idTokenClaims.nonce = nonce;
|
||||||
|
}
|
||||||
|
if (profileClaims.name) idTokenClaims.name = profileClaims.name;
|
||||||
|
if (profileClaims.preferredUsername) idTokenClaims.preferred_username = profileClaims.preferredUsername;
|
||||||
|
if (profileClaims.picture) idTokenClaims.picture = profileClaims.picture;
|
||||||
|
if (profileClaims.email) {
|
||||||
|
idTokenClaims.email = profileClaims.email;
|
||||||
|
idTokenClaims.email_verified = profileClaims.emailVerified ?? false;
|
||||||
|
}
|
||||||
|
if (profileClaims.phoneNumber) {
|
||||||
|
idTokenClaims.phone_number = profileClaims.phoneNumber;
|
||||||
|
idTokenClaims.phone_number_verified = profileClaims.phoneNumberVerified ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToken = await this.jwt.signAsync(idTokenClaims, {
|
||||||
|
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
|
||||||
|
expiresIn: '15m',
|
||||||
|
issuer
|
||||||
|
});
|
||||||
|
|
||||||
return { accessToken, tokenType: 'Bearer', expiresIn: 900, refreshToken, idToken };
|
return { accessToken, tokenType: 'Bearer', expiresIn: 900, refreshToken, idToken };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ message AuthorizeRequest {
|
|||||||
string redirectUri = 3;
|
string redirectUri = 3;
|
||||||
string scope = 4;
|
string scope = 4;
|
||||||
optional string state = 5;
|
optional string state = 5;
|
||||||
|
optional string nonce = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AuthorizeResponse {
|
message AuthorizeResponse {
|
||||||
@@ -79,6 +80,10 @@ message UserInfoResponse {
|
|||||||
optional string phone = 3;
|
optional string phone = 3;
|
||||||
string name = 4;
|
string name = 4;
|
||||||
optional string picture = 5;
|
optional string picture = 5;
|
||||||
|
optional bool email_verified = 6;
|
||||||
|
optional string preferred_username = 7;
|
||||||
|
optional string phone_number = 8;
|
||||||
|
optional bool phone_number_verified = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SendOtpRequest {
|
message SendOtpRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user