-
Notifications
You must be signed in to change notification settings - Fork 87
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
> 基于 #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
Showing
8 changed files
with
582 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} | ||
} |
Oops, something went wrong.