Skip to content

Commit

Permalink
feat: support granular token (#443)
Browse files Browse the repository at this point in the history
> 🚀 Added implementation related to
[granularToken](https://docs.npmjs.com/about-access-tokens#about-granular-access-tokens),
mainly used for web authorization scenarios.

* 📝 Added `1.14.0.sql` to add fields and `token_packages` for
granularToken.
* 🛣️ Added gat related routes, including `create`, `query`, and `delete`
api.
* 🌟 Added `tokenService` to check granularToken access.
* 🔄 Modified Token to perform options and data attribute conversions
internally in the model.
-----------

> 🚀 新增
[granularToken](https://docs.npmjs.com/about-access-tokens#about-granular-access-tokens)
相关实现,主要用于 web 端授权场景
* 📝 新增 `1.14.0.sql` 添加 granularToken 相关字段及 `token_packages` 中间表
* 🛣️ 新增 gat 相关路由,包括`创建`、`查询`、`删除`接口
* 🌟 新增 `tokenService` ,处理 granularToken 鉴权
* 🔄 修改 Token ,在 model 内部进行 options 和 data 属性转换
  • Loading branch information
elrrrrrrr authored Apr 20, 2023
1 parent 6961ffb commit 92ddf2c
Show file tree
Hide file tree
Showing 14 changed files with 722 additions and 35 deletions.
58 changes: 51 additions & 7 deletions app/core/entity/Token.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
import dayjs from 'dayjs';
import { Entity, EntityData } from './Entity';
import { EasyData, EntityUtil } from '../util/EntityUtil';

interface TokenData extends EntityData {
export enum TokenType {
granular = 'granular',
classic = 'classic',
}
interface BaseTokenData extends EntityData {
tokenId: string;
tokenMark: string;
tokenKey: string;
cidrWhitelist: string[];
cidrWhitelist?: string[];
userId: string;
isReadonly: boolean;
isAutomation: boolean;
isReadonly?: boolean;
type?: TokenType;
}

interface ClassicTokenData extends BaseTokenData{
isAutomation?: boolean;
}
interface GranularTokenData extends BaseTokenData {
name: string;
description?: string;
allowedScopes?: string[];
allowedPackages?: string[];
expires: number;
expiredAt: Date;
}

type TokenData = ClassicTokenData | GranularTokenData;

export function isGranularToken(data: TokenData): data is GranularTokenData {
return data.type === TokenType.granular;
}

export class Token extends Entity {
Expand All @@ -19,6 +42,13 @@ export class Token extends Entity {
readonly userId: string;
readonly isReadonly: boolean;
readonly isAutomation: boolean;
readonly type?: TokenType;
readonly name?: string;
readonly description?: string;
readonly allowedScopes?: string[];
readonly expiredAt?: Date;
readonly expires?: number;
allowedPackages?: string[];
token?: string;

constructor(data: TokenData) {
Expand All @@ -27,13 +57,27 @@ export class Token extends Entity {
this.tokenId = data.tokenId;
this.tokenMark = data.tokenMark;
this.tokenKey = data.tokenKey;
this.cidrWhitelist = data.cidrWhitelist;
this.isReadonly = data.isReadonly;
this.isAutomation = data.isAutomation;
this.cidrWhitelist = data.cidrWhitelist || [];
this.isReadonly = data.isReadonly || false;
this.type = data.type || TokenType.classic;

if (isGranularToken(data)) {
this.name = data.name;
this.description = data.description;
this.allowedScopes = data.allowedScopes;
this.expiredAt = data.expiredAt;
this.allowedPackages = data.allowedPackages;
} else {
this.isAutomation = data.isAutomation || false;
}
}

static create(data: EasyData<TokenData, 'tokenId'>): Token {
const newData = EntityUtil.defaultData(data, 'tokenId');
if (isGranularToken(newData) && !newData.expiredAt) {
newData.expiredAt = dayjs(newData.createdAt).add(newData.expires, 'days').toDate();
}
return new Token(newData);
}

}
70 changes: 70 additions & 0 deletions app/core/service/TokenService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import dayjs from 'dayjs';
import {
AccessLevel,
SingletonProto,
Inject,
} from '@eggjs/tegg';
import { isEmpty } from 'lodash';
import { AbstractService } from '../../common/AbstractService';
import { Token, isGranularToken } from '../entity/Token';
import { TokenPackage as TokenPackageModel } from '../../../app/repository/model/TokenPackage';
import { Package as PackageModel } from '../../../app/repository/model/Package';
import { ModelConvertor } from '../../../app/repository/util/ModelConvertor';
import { Package as PackageEntity } from '../entity/Package';
import { ForbiddenError, UnauthorizedError } from 'egg-errors';
import { getScopeAndName } from '../../../app/common/PackageUtil';

@SingletonProto({
accessLevel: AccessLevel.PUBLIC,
})
export class TokenService extends AbstractService {
@Inject()
private readonly TokenPackage: typeof TokenPackageModel;
@Inject()
private readonly Package: typeof PackageModel;

public async listTokenPackages(token: Token) {
if (isGranularToken(token)) {
const models = await this.TokenPackage.find({ tokenId: token.tokenId });
const packages = await this.Package.find({ packageId: models.map(m => m.packageId) });
return packages.map(pkg => ModelConvertor.convertModelToEntity(pkg, PackageEntity));
}
return null;
}

public async checkGranularTokenAccess(token: Token, fullname: string) {
// skip classic token
if (!isGranularToken(token)) {
return true;
}

// check for expires
if (dayjs(token.expiredAt).isBefore(new Date())) {
throw new UnauthorizedError('Token expired');
}

// check for scope whitelist
const [ scope, name ] = getScopeAndName(fullname);
// check for packages whitelist
const allowedPackages = await this.listTokenPackages(token);

// check for scope & packages access
if (isEmpty(allowedPackages) && isEmpty(token.allowedScopes)) {
return true;
}

const existPkgConfig = allowedPackages?.find(pkg => pkg.scope === scope && pkg.name === name);
if (existPkgConfig) {
return true;
}

const existScopeConfig = token.allowedScopes?.find(s => s === scope);
if (existScopeConfig) {
return true;
}

throw new ForbiddenError(`can't access package "${fullname}"`);

}

}
24 changes: 18 additions & 6 deletions app/core/service/UserService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import { NotFoundError, ForbiddenError } from 'egg-errors';
import { UserRepository } from '../../repository/UserRepository';
import { User as UserEntity } from '../entity/User';
import { Token as TokenEntity } from '../entity/Token';
import { Token as TokenEntity, TokenType } from '../entity/Token';
import { WebauthnCredential as WebauthnCredentialEntity } from '../entity/WebauthnCredential';
import { LoginResultCode } from '../../common/enum/User';
import { integrity, checkIntegrity, randomToken, sha512 } from '../../common/UserUtil';
Expand All @@ -28,7 +28,20 @@ type LoginResult = {
token?: TokenEntity;
};

type CreateTokenOptions = {
type CreateTokenOption = CreateClassicTokenOptions | CreateGranularTokenOptions;

type CreateGranularTokenOptions = {
type: TokenType.granular;
name: string;
description?: string;
allowedScopes?: string[];
allowedPackages?: string[];
isReadonly?: boolean;
cidrWhitelist?: string[];
expires: number;
};

type CreateClassicTokenOptions = {
isReadonly?: boolean;
isAutomation?: boolean;
cidrWhitelist?: string[];
Expand Down Expand Up @@ -126,19 +139,18 @@ export class UserService extends AbstractService {
return { changed: true, user };
}

async createToken(userId: string, options: CreateTokenOptions = {}) {
async createToken(userId: string, options: CreateTokenOption = {}) {
// https://github.blog/2021-09-23-announcing-npms-new-access-token-format/
// https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/
// https://github.blog/changelog/2022-12-06-limit-scope-of-npm-tokens-with-the-new-granular-access-tokens/
const token = randomToken(this.config.cnpmcore.name);
const tokenKey = sha512(token);
const tokenMark = token.substring(0, token.indexOf('_') + 4);
const tokenEntity = TokenEntity.create({
tokenKey,
tokenMark,
userId,
cidrWhitelist: options.cidrWhitelist ?? [],
isReadonly: options.isReadonly ?? false,
isAutomation: options.isAutomation ?? false,
...options,
});
await this.userRepository.saveToken(tokenEntity);
tokenEntity.token = token;
Expand Down
18 changes: 13 additions & 5 deletions app/port/UserRoleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Token as TokenEntity } from '../core/entity/Token';
import { sha512 } from '../common/UserUtil';
import { getScopeAndName } from '../common/PackageUtil';
import { RegistryManagerService } from '../core/service/RegistryManagerService';
import { TokenService } from '../core/service/TokenService';

// https://docs.npmjs.com/creating-and-viewing-access-tokens#creating-tokens-on-the-website
export type TokenRole = 'read' | 'publish' | 'setting';
Expand All @@ -33,6 +34,8 @@ export class UserRoleManager {
protected logger: EggLogger;
@Inject()
private readonly registryManagerService: RegistryManagerService;
@Inject()
private readonly tokenService: TokenService;

private handleAuthorized = false;
private currentAuthorizedUser: UserEntity;
Expand All @@ -47,31 +50,36 @@ export class UserRoleManager {

const user = await this.requiredAuthorizedUser(ctx, 'publish');

// 1. admin chas all access
// 1. admin has all access
const isAdmin = await this.isAdmin(ctx);
if (isAdmin) {
return user;
}

// 2. has published in current registry
// 2. check for checkGranularTokenAccess
const authorizedUserAndToken = await this.getAuthorizedUserAndToken(ctx);
const { token } = authorizedUserAndToken!;
await this.tokenService.checkGranularTokenAccess(token, fullname);

// 3. has published in current registry
const [ scope, name ] = getScopeAndName(fullname);
const pkg = await this.packageRepository.findPackage(scope, name);
const selfRegistry = await this.registryManagerService.ensureSelfRegistry();
const inSelfRegistry = pkg?.registryId === selfRegistry.registryId;
if (inSelfRegistry) {
// 2.1 check in Maintainers table
// 3.1 check in Maintainers table
// Higher priority than scope check
await this.requiredPackageMaintainer(pkg, user);
return user;
}

if (pkg && !scope && !inSelfRegistry) {
// 2.2 public package can't publish in other registry
// 3.2 public package can't publish in other registry
// scope package can be migrated into self registry
throw new ForbiddenError(`Can\'t modify npm public package "${fullname}"`);
}

// 3 check scope is allowed to publish
// 4 check scope is allowed to publish
await this.requiredPackageScope(scope, user);
if (pkg) {
// published scoped package
Expand Down
Loading

0 comments on commit 92ddf2c

Please sign in to comment.