Skip to content

Commit

Permalink
feat: support webauth infra (#411)
Browse files Browse the repository at this point in the history
> 基于 #380 ,新增 infra 层,允许自定义 authUrl
、新增 SSO 登录方法

* 从 `app/webauth` 移动至 `app/port`,取消独立 module
* 新增 SSORequest 方法,作为 SSO 内置方法
* 新增 authAdapter,因为 npm cli 请求地址是固定的
* 单测补全

------------

> New infra layer based on #380 ,
allowing custom the authUrl and SSO.

* Moved from `app/webauth` to `app/port`, normlize the controller
* New SSORequest method as SSO preset login
* New authAdapter, since npm cli request addresses are fixed
* TestCase updated

![image](https://user-images.githubusercontent.com/5574625/220271869-0b4d96c6-0d89-499e-9c74-eff2727749cb.png)

---------

Co-authored-by: fengmk2 <fengmk2@gmail.com>
  • Loading branch information
elrrrrrrr and fengmk2 committed Feb 21, 2023
1 parent 4f1555a commit 583437a
Show file tree
Hide file tree
Showing 8 changed files with 582 additions and 4 deletions.
13 changes: 12 additions & 1 deletion DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ npm run test

## 项目结构

```
```txt
app
├── common
│ └── adapter
Expand All @@ -58,6 +58,8 @@ app
│ └── controller
├── repository
│ └── model
├── infra
│ └── NFSClientAdapter.ts
└── test
├── control
│ └── response_time.test.js
Expand Down Expand Up @@ -86,6 +88,15 @@ port:

- controller:HTTP controller

infra:

基于 PaaS 基础设置实现各种 adapter 真实适配实现,cnpmcore 会内置一种实现,企业自定义的 cnpmcore 应该自行基于自身的
PaaS 环境实现自己的 infra module。

- NFSClientAdapter.ts
- QueueAdapter.ts
- AuthAdapter.ts

## Controller 开发指南

目前只支持 HTTP 协议的 Controller,代码在 `app/port/controller` 目录下。
Expand Down
2 changes: 1 addition & 1 deletion app/common/adapter/CacheAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const ONE_DAY = 3600 * 24;
})
export class CacheAdapter {
@Inject()
private readonly redis: Redis;
private readonly redis: Redis; // 由 redis 插件引入

async setBytes(key: string, bytes: Buffer) {
await this.redis.setex(key, ONE_DAY, bytes);
Expand Down
15 changes: 15 additions & 0 deletions app/common/typing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Readable } from 'stream';
import { IncomingHttpHeaders } from 'http';
import { EggContext } from '@eggjs/tegg';

export interface UploadResult {
key: string;
Expand Down Expand Up @@ -41,3 +42,17 @@ export interface QueueAdapter {
pop<T>(key: string): Promise<T | null>;
length(key: string): Promise<number>;
}

export interface AuthUrlResult {
loginUrl: string;
doneUrl: string;
}

export interface userResult {
name: string;
email: string;
}
export interface AuthClient {
getAuthUrl(ctx: EggContext): Promise<AuthUrlResult>;
ensureCurrentUser(): Promise<userResult | null>;
}
21 changes: 20 additions & 1 deletion app/core/service/UserService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import { LoginResultCode } from '../../common/enum/User';
import { integrity, checkIntegrity, randomToken, sha512 } from '../../common/UserUtil';
import { AbstractService } from '../../common/AbstractService';

type Optional<T, K extends keyof T> = Omit < T, K > & Partial<T> ;

type CreateUser = {
name: string;
password: string;
email: string;
password: string;
ip: string;
};

Expand Down Expand Up @@ -53,6 +55,23 @@ export class UserService extends AbstractService {
return { code: LoginResultCode.Success, user, token };
}

async ensureTokenByUser({ name, email, password = crypto.randomUUID(), ip }: Optional<CreateUser, 'password'>) {
let user = await this.userRepository.findUserByName(name);
if (!user) {
const createRes = await this.create({
name,
email,
// Authentication via sso
// should use token instead of password
password,
ip,
});
user = createRes.user;
}
const token = await this.createToken(user.userId);
return token;
}

async create(createUser: CreateUser) {
const passwordSalt = crypto.randomBytes(30).toString('hex');
const plain = `${passwordSalt}${createUser.password}`;
Expand Down
50 changes: 50 additions & 0 deletions app/infra/AuthAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
AccessLevel,
EggContext,
Inject,
SingletonProto,
} from '@eggjs/tegg';
import { Redis } from 'ioredis';
import { randomUUID } from 'crypto';
import { AuthClient, AuthUrlResult, userResult } from '../common/typing';

const ONE_DAY = 3600 * 24;

type SSO_USER = {
name: string;
email: string;
};

/**
* Use sort set to keep queue in order and keep same value only insert once
*/
@SingletonProto({
accessLevel: AccessLevel.PUBLIC,
name: 'authAdapter',
})
export class AuthAdapter implements AuthClient {
@Inject()
readonly redis: Redis;

@Inject()
readonly user: SSO_USER;

async getAuthUrl(ctx: EggContext): Promise<AuthUrlResult> {
const sessionId = randomUUID();
await this.redis.setex(sessionId, ONE_DAY, '');
return {
loginUrl: `${ctx.href}/request/session/${sessionId}`,
doneUrl: `${ctx.href}/done/session/${sessionId}`,
};
}

// should implements in infra
async ensureCurrentUser() {
if (this.user) {
const { name, email } = this.user;
return { name, email } as userResult;
}
return null;
}

}
2 changes: 1 addition & 1 deletion app/infra/QueueAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { QueueAdapter } from '../common/typing';
})
export class RedisQueueAdapter implements QueueAdapter {
@Inject()
private readonly redis: Redis;
private readonly redis: Redis; // 由 redis 插件引入

private getQueueName(key: string) {
return `CNPMCORE_Q_V2_${key}`;
Expand Down
196 changes: 196 additions & 0 deletions app/port/webauth/WebauthController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import {
Inject,
HTTPController,
HTTPMethod,
HTTPMethodEnum,
HTTPParam,
HTTPBody,
Context,
EggContext,
} from '@eggjs/tegg';
import {
EggLogger,
EggAppConfig,
} from 'egg';
import { Static, Type } from '@sinclair/typebox';
import { ForbiddenError, NotFoundError } from 'egg-errors';
import { LoginResultCode } from '../../common/enum/User';
import { CacheAdapter } from '../../common/adapter/CacheAdapter';
import { UserService } from '../../core/service/UserService';
import { MiddlewareController } from '../middleware';
import { AuthAdapter } from '../../infra/AuthAdapter';

const LoginRequestRule = Type.Object({
// cli 所在机器的 hostname
hostname: Type.String({ minLength: 1, maxLength: 100 }),
});
type LoginRequest = Static<typeof LoginRequestRule>;

const UserRule = Type.Object({
name: Type.String({ minLength: 1, maxLength: 100 }),
password: Type.String({ minLength: 8, maxLength: 100 }),
});
type User = Static<typeof UserRule>;

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);
}

private setBasicAuth(ctx: EggContext) {
ctx.status = 401;
ctx.set('WWW-Authenticate', 'Basic realm="Login to cnpmcore"');
}

@HTTPMethod({
path: '/-/v1/login/request/session/:sessionId',
method: HTTPMethodEnum.GET,
})
async loginRequest(@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>';
}
// Basic auth
const authorization = ctx.get('authorization');
if (!authorization) {
this.setBasicAuth(ctx);
return 'Unauthorized';
}
// 'Basic xxxx=='
if (!authorization.startsWith('Basic ')) {
this.setBasicAuth(ctx);
return 'Unauthorized, invalid authorization, only support "Basic" authorization';
}
const base64String = authorization.replace('Basic ', '');
// {user}:{pass}
const userAuth = Buffer.from(base64String, 'base64').toString();
const sepIndex = userAuth.indexOf(':');
const username = userAuth.substring(0, sepIndex);
const password = userAuth.substring(sepIndex + 1);
const user: User = {
name: username,
password,
};
try {
ctx.tValidate(UserRule, user);
} catch (err) {
const message = err.message;
this.setBasicAuth(ctx);
return `Unauthorized, ${message}`;
}

if (this.config.cnpmcore.allowPublicRegistration === false) {
if (!this.config.cnpmcore.admins[user.name]) {
ctx.status = 403;
return '<h1>😭😭😭 Public registration is not allowed 😭😭😭</h1>';
}
}

const result = await this.userService.login(user.name, user.password);
// user exists and password not match
if (result.code === LoginResultCode.Fail) {
this.setBasicAuth(ctx);
return '<h1>😭😭😭 Please check your login name and password 😭😭😭</h1>';
}

let token = '';
if (result.code === LoginResultCode.Success) {
// login success
token = result.token!.token!;
} else {
// others: LoginResultCode.UserNotFound
// create user request
const createRes = await this.userService.ensureTokenByUser({
name: user.name,
password: user.password,
// FIXME: email verify
email: `${user.name}@webauth.cnpmjs.org`,
ip: ctx.ip,
});
token = createRes.token!;
}

await this.cacheAdapter.set(sessionId, token);
ctx.redirect('/-/v1/login/request/success');
}

@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!);

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);
return { token };
}
}
Loading

0 comments on commit 583437a

Please sign in to comment.