Files
cnpmcore/app/port/webauth/WebauthController.ts

464 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type EggContext,
Context,
HTTPBody,
HTTPController,
HTTPMethod,
HTTPMethodEnum,
HTTPParam,
HTTPQuery,
Inject,
} from '@eggjs/tegg';
import type { EggAppConfig, EggLogger } from 'egg';
import '@eggjs/typebox-validate';
import { Type, type Static } from '@eggjs/typebox-validate/typebox';
import { ForbiddenError, NotFoundError } from 'egg-errors';
import { createHash } from 'node:crypto';
import base64url from 'base64url';
import {
type VerifyAuthenticationResponseOpts,
type VerifyRegistrationResponseOpts,
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import type {
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/typescript-types';
import { LoginResultCode, WanStatusCode } from '../../common/enum/User.js';
import type { CacheAdapter } from '../../common/adapter/CacheAdapter.js';
import type { UserService } from '../../core/service/UserService.js';
import { MiddlewareController } from '../middleware/index.js';
import type { AuthAdapter } from '../../infra/AuthAdapter.js';
import { decryptRSA, genRSAKeys } from '../../common/CryptoUtil.js';
import { getBrowserTypeForWebauthn } from '../../common/UserUtil.js';
const LoginRequestRule = Type.Object({
// cli 所在机器的 hostname最新版本 npm cli 已经不会上报 hostname
hostname: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
});
type LoginRequest = Static<typeof LoginRequestRule>;
interface LoginPrepareResult {
wanStatus: number;
wanCredentialRegiOption?: PublicKeyCredentialCreationOptionsJSON;
wanCredentialAuthOption?: PublicKeyCredentialRequestOptionsJSON;
}
interface LoginImplementRequest {
accData: {
username: string;
password: string;
};
wanCredentialRegiData: unknown;
wanCredentialAuthData: unknown;
needUnbindWan: boolean;
}
const UserRule = Type.Object({
name: Type.String({ minLength: 1, maxLength: 100 }),
password: Type.String({ minLength: 8, maxLength: 100 }),
});
const SessionRule = Type.Object({
// uuid
sessionId: Type.String({ minLength: 36, maxLength: 36 }),
});
@HTTPController()
export class WebauthController extends MiddlewareController {
@Inject()
private cacheAdapter: CacheAdapter;
@Inject()
private authAdapter: AuthAdapter;
@Inject()
protected logger: EggLogger;
@Inject()
protected config: EggAppConfig;
@Inject()
protected userService: UserService;
// https://github.com/cnpm/cnpmcore/issues/348
@HTTPMethod({
path: '/-/v1/login',
method: HTTPMethodEnum.POST,
})
async login(
@Context() ctx: EggContext,
@HTTPBody() loginRequest: LoginRequest
) {
ctx.tValidate(LoginRequestRule, loginRequest);
return this.authAdapter.getAuthUrl(ctx);
}
@HTTPMethod({
path: '/-/v1/login/request/session/:sessionId',
method: HTTPMethodEnum.GET,
})
async loginRender(
@Context() ctx: EggContext,
@HTTPParam() sessionId: string
) {
ctx.tValidate(SessionRule, { sessionId });
ctx.type = 'html';
const sessionToken = await this.cacheAdapter.get(sessionId);
if (typeof sessionToken !== 'string') {
ctx.status = 404;
return '<h1>😭😭😭 Session not found, please try again on your command line 😭😭😭</h1>';
}
const keys = genRSAKeys();
await this.cacheAdapter.set(`${sessionId}_privateKey`, keys.privateKey);
await ctx.render('login.html', {
sessionId,
publicKey: keys.publicKey,
enableWebauthn: this.config.cnpmcore.enableWebAuthn,
});
}
@HTTPMethod({
path: '/-/v1/login/request/session/:sessionId',
method: HTTPMethodEnum.POST,
})
async loginImplement(
@Context() ctx: EggContext,
@HTTPParam() sessionId: string,
@HTTPBody() loginImplementRequest: LoginImplementRequest
) {
ctx.tValidate(SessionRule, { sessionId });
const sessionToken = await this.cacheAdapter.get(sessionId);
if (typeof sessionToken !== 'string') {
return {
ok: false,
message: 'Session not found, please try again on your command line',
};
}
const {
accData,
wanCredentialRegiData,
wanCredentialAuthData,
needUnbindWan,
} = loginImplementRequest;
const { username, password = '' } = accData;
const enableWebAuthn = this.config.cnpmcore.enableWebAuthn;
const isSupportWebAuthn =
ctx.protocol === 'https' || ctx.hostname === 'localhost';
let token = '';
let user;
// public registration
if (
this.config.cnpmcore.allowPublicRegistration === false &&
!this.config.cnpmcore.admins[username]
) {
return { ok: false, message: 'Public registration is not allowed' };
}
const browserType =
getBrowserTypeForWebauthn(ctx.headers['user-agent']) || undefined;
const expectedChallenge =
(await this.cacheAdapter.get(`${sessionId}_challenge`)) || '';
const expectedOrigin = this.config.cnpmcore.registry;
const expectedRPID = new URL(expectedOrigin).hostname;
// webauthn authentication
if (enableWebAuthn && isSupportWebAuthn && wanCredentialAuthData) {
user = await this.userService.findUserByName(username);
if (!user) {
return {
ok: false,
message: 'Unauthorized, Please check your login name',
};
}
const credential = await this.userService.findWebauthnCredential(
user.userId,
browserType
);
if (!credential?.credentialId || !credential?.publicKey) {
return {
ok: false,
message: 'Unauthorized, Please check your login name',
};
}
try {
const verification = await verifyAuthenticationResponse({
response:
wanCredentialAuthData as VerifyAuthenticationResponseOpts['response'],
expectedChallenge,
expectedOrigin,
expectedRPID,
authenticator: {
// @ts-expect-error type error
credentialPublicKey: base64url.toBuffer(credential.publicKey),
// @ts-expect-error type error
credentialID: base64url.toBuffer(credential.credentialId),
counter: 0,
},
});
const { verified } = verification;
if (!verified) {
return {
ok: false,
message:
'Invalid security arguments, please try again on your browser',
};
}
} catch (err) {
this.logger.error(
'[WebauthController.loginImplement:verify-authentication-fail] expectedChallenge: %s, expectedOrigin: %s, expectedRPID: %s, wanCredentialAuthData: %j, error: %j',
expectedChallenge,
expectedOrigin,
expectedRPID,
wanCredentialAuthData,
err
);
return {
ok: false,
message:
'Authentication failed, please continue to sign in with your password',
};
}
const createToken = await this.userService.createToken(user.userId);
token = createToken.token;
await this.cacheAdapter.set(sessionId, token);
return { ok: true };
}
// check privateKey valid
const privateKey = await this.cacheAdapter.get(`${sessionId}_privateKey`);
if (!privateKey) {
return {
ok: false,
message: 'Invalid security arguments, please try again on your browser',
};
}
// check login name and password valid
const realPassword = decryptRSA(privateKey, password);
try {
ctx.tValidate(UserRule, {
name: username,
password: realPassword,
});
} catch (err) {
const message = err.message;
return { ok: false, message: `Unauthorized, ${message}` };
}
const result = await this.userService.login(username, realPassword);
// user exists and password not match
if (result.code === LoginResultCode.Fail) {
return {
ok: false,
message: 'Please check your login name and password',
};
}
if (result.code === LoginResultCode.Success) {
// login success
// oxlint-disable-next-line typescript-eslint/no-non-null-assertion
token = result.token!.token;
user = result.user;
// need unbind webauthn credential
if (needUnbindWan) {
await this.userService.removeWebauthnCredential(
user?.userId,
browserType
);
}
} else {
// others: LoginResultCode.UserNotFound
// create user request
const createRes = await this.userService.ensureTokenByUser({
name: username,
password: realPassword,
// FIXME: email verify
email: `${username}@webauth.cnpmjs.org`,
ip: ctx.ip,
});
token = createRes.token.token;
user = createRes.user;
}
await this.cacheAdapter.set(sessionId, token);
// webauthn registration
if (enableWebAuthn && isSupportWebAuthn && wanCredentialRegiData) {
try {
const verification = await verifyRegistrationResponse({
response:
wanCredentialRegiData as VerifyRegistrationResponseOpts['response'],
expectedChallenge,
expectedOrigin,
expectedRPID,
});
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const { credentialPublicKey, credentialID } = registrationInfo;
// @ts-expect-error type error
const base64CredentialPublicKey = base64url.encode(
Buffer.from(new Uint8Array(credentialPublicKey))
);
// @ts-expect-error type error
const base64CredentialID = base64url.encode(
Buffer.from(new Uint8Array(credentialID))
);
this.userService.createWebauthnCredential(user?.userId, {
credentialId: base64CredentialID,
publicKey: base64CredentialPublicKey,
browserType,
});
}
} catch (err) {
this.logger.error(
'[WebauthController.loginImplement:verify-registration-fail] expectedChallenge: %s, expectedOrigin: %s, expectedRPID: %s, wanCredentialRegiData: %j, error: %j',
expectedChallenge,
expectedOrigin,
expectedRPID,
wanCredentialRegiData,
err
);
}
}
return { ok: true };
}
@HTTPMethod({
path: '/-/v1/login/request/prepare/:sessionId',
method: HTTPMethodEnum.GET,
})
async loginPrepare(
@Context() ctx: EggContext,
@HTTPParam() sessionId: string,
@HTTPQuery() name: string
) {
ctx.tValidate(SessionRule, { sessionId });
const sessionToken = await this.cacheAdapter.get(sessionId);
if (typeof sessionToken !== 'string') {
return {
ok: false,
message: 'Session not found, please try again on your command line',
};
}
const browserType = getBrowserTypeForWebauthn(ctx.headers['user-agent']);
const expectedRPID = new URL(this.config.cnpmcore.registry).hostname;
const user = await this.userService.findUserByName(name);
const result: LoginPrepareResult = {
wanStatus: WanStatusCode.UserNotFound,
};
let credential;
if (user) {
credential = await this.userService.findWebauthnCredential(
user.userId,
browserType
);
result.wanStatus = WanStatusCode.Unbound;
}
if (credential?.credentialId && credential?.publicKey) {
result.wanStatus = WanStatusCode.Bound;
result.wanCredentialAuthOption = generateAuthenticationOptions({
timeout: 60_000,
rpID: expectedRPID,
allowCredentials: [
{
// @ts-expect-error type error
id: base64url.toBuffer(credential.credentialId),
type: 'public-key',
transports: ['internal'],
},
],
});
await this.cacheAdapter.set(
`${sessionId}_challenge`,
result.wanCredentialAuthOption.challenge
);
} else {
const encoder = new TextEncoder();
const regUserIdBuffer = createHash('sha256')
.update(encoder.encode(name))
.digest();
result.wanCredentialRegiOption = generateRegistrationOptions({
rpName: ctx.app.config.name,
rpID: expectedRPID,
// @ts-expect-error type error
userID: base64url.encode(Buffer.from(regUserIdBuffer)),
userName: name,
userDisplayName: name,
timeout: 60_000,
attestationType: 'direct',
authenticatorSelection: {
authenticatorAttachment: 'platform',
},
});
await this.cacheAdapter.set(
`${sessionId}_challenge`,
result.wanCredentialRegiOption.challenge
);
}
return result;
}
@HTTPMethod({
path: '/-/v1/login/sso/:sessionId',
method: HTTPMethodEnum.POST,
})
async ssoRequest(@Context() ctx: EggContext, @HTTPParam() sessionId: string) {
ctx.tValidate(SessionRule, { sessionId });
const sessionData = await this.cacheAdapter.get(sessionId);
if (sessionData !== '') {
throw new ForbiddenError('invalid sessionId');
}
// get current userInfo from infra
// @see https://github.com/eggjs/egg-userservice
const userRes = await this.authAdapter.ensureCurrentUser();
if (!userRes?.name || !userRes?.email) {
throw new ForbiddenError('invalid user info');
}
const { name, email } = userRes;
const { token } = await this.userService.ensureTokenByUser({
name,
email,
ip: ctx.ip,
});
await this.cacheAdapter.set(sessionId, token.token);
return { success: true };
}
@HTTPMethod({
path: '/-/v1/login/request/success',
method: HTTPMethodEnum.GET,
})
async loginRequestSuccess(@Context() ctx: EggContext) {
ctx.type = 'html';
return `<h1>😁😁😁 Authorization Successful 😁😁😁</h1>
<p>You can close this tab and return to your command line.</p>`;
}
@HTTPMethod({
path: '/-/v1/login/done/session/:sessionId',
method: HTTPMethodEnum.GET,
})
async loginDone(@Context() ctx: EggContext, @HTTPParam() sessionId: string) {
ctx.tValidate(SessionRule, { sessionId });
const token = await this.cacheAdapter.get(sessionId);
if (typeof token !== 'string') {
throw new NotFoundError('session not found');
}
if (token === '') {
ctx.status = 202;
ctx.set('retry-after', '1');
return { message: 'processing' };
}
// only get once
await this.cacheAdapter.delete(sessionId);
await this.cacheAdapter.delete(`${sessionId}_challenge`);
await this.cacheAdapter.delete(`${sessionId}_privateKey`);
return { token };
}
}