server: allow to grant tokens with more restricted privileges

This also simplifies API authentication a bit by not having to fetch
the App that is related to a token.

The restriction of 1 token per app is also lifted. This was not a
constraint in the database but it was enforced by the code and
kinda wrong schema the auth_session table had.
This commit is contained in:
Johann150 2022-11-05 22:17:51 +01:00 committed by Gitea
parent 2f2e6a58a4
commit 79e3c20189
8 changed files with 118 additions and 85 deletions

View file

@ -0,0 +1,26 @@
export class tokenPermissions1667653936442 {
name = 'tokenPermissions1667653936442'
async up(queryRunner) {
// Carry over the permissions from the app for tokens that have an associated app.
await queryRunner.query(`UPDATE "access_token" SET permission = (SELECT permission FROM "app" WHERE "app"."id" = "access_token"."appId") WHERE "appId" IS NOT NULL AND CARDINALITY("permission") = 0`);
// The permission column should now always be set explicitly, so the default is not needed any more.
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" DROP DEFAULT`);
// Refactor scheme to allow multiple access tokens per app.
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "FK_c072b729d71697f959bde66ade0"`);
await queryRunner.query(`ALTER TABLE "auth_session" RENAME COLUMN "userId" TO "accessTokenId"`);
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66" UNIQUE ("accessTokenId")`);
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "FK_8e001e5a101c6dca37df1a76d66" FOREIGN KEY ("accessTokenId") REFERENCES "access_token"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "FK_8e001e5a101c6dca37df1a76d66"`);
await queryRunner.query(`ALTER TABLE "auth_session" DROP CONSTRAINT "UQ_8e001e5a101c6dca37df1a76d66"`);
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "auth_session" RENAME COLUMN "accessTokenId" TO "userId"`);
await queryRunner.query(`ALTER TABLE "auth_session" ADD CONSTRAINT "FK_c072b729d71697f959bde66ade0" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "access_token" ALTER COLUMN "permission" SET DEFAULT '{}'::varchar[]`);
await queryRunner.query(`UPDATE "access_token" SET permission = '{}'::varchar[] WHERE "appId" IS NOT NULL`);
}
}

View file

@ -1,6 +1,6 @@
import { Entity, PrimaryColumn, Index, Column, ManyToOne, JoinColumn } from 'typeorm'; import { Entity, PrimaryColumn, Index, Column, ManyToOne, OneToOne, JoinColumn } from 'typeorm';
import { id } from '../id.js'; import { id } from '../id.js';
import { User } from './user.js'; import { AccessToken } from './access-token.js';
import { App } from './app.js'; import { App } from './app.js';
@Entity() @Entity()
@ -23,19 +23,19 @@ export class AuthSession {
...id(), ...id(),
nullable: true, nullable: true,
}) })
public userId: User['id'] | null; public accessTokenId: AccessToken['id'] | null;
@ManyToOne(type => User, { @ManyToOne(() => AccessToken, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
nullable: true, nullable: true,
}) })
@JoinColumn() @JoinColumn()
public user: User | null; public accessToken: AccessToken | null;
@Column(id()) @Column(id())
public appId: App['id']; public appId: App['id'];
@ManyToOne(type => App, { @ManyToOne(() => App, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn() @JoinColumn()

View file

@ -1,16 +1,9 @@
import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; import { CacheableLocalUser } from '@/models/entities/user.js';
import { Users, AccessTokens, Apps } from '@/models/index.js'; import { Users, AccessTokens } from '@/models/index.js';
import { AccessToken } from '@/models/entities/access-token.js'; import { AccessToken } from '@/models/entities/access-token.js';
import { Cache } from '@/misc/cache.js';
import { App } from '@/models/entities/app.js';
import { userByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js'; import { userByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
import isNativeToken from './common/is-native-token.js'; import isNativeToken from './common/is-native-token.js';
const appCache = new Cache<App>(
Infinity,
(id) => Apps.findOneByOrFail({ id }),
);
export class AuthenticationError extends Error { export class AuthenticationError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
@ -71,15 +64,6 @@ export default async (authorization: string | null | undefined, bodyToken: strin
// can't authorize remote users // can't authorize remote users
if (!Users.isLocalUser(user)) return [null, null]; if (!Users.isLocalUser(user)) return [null, null];
if (accessToken.appId) { return [user, accessToken];
const app = await appCache.fetch(accessToken.appId);
return [user, {
id: accessToken.id,
permission: app.permission,
} as AccessToken];
} else {
return [user, accessToken];
}
} }
}; };

View file

@ -51,11 +51,16 @@ export async function oauth(ctx: Koa.Context): void {
id: client_id, id: client_id,
secret: client_secret, secret: client_secret,
}), }),
AuthSessions.findOneBy({ AuthSessions.findOne({
appId: client_id, where: {
token: code, appId: client_id,
// only check for approved auth sessions token: code,
userId: Not(IsNull()), // only check for approved auth sessions
accessTokenId: Not(IsNull()),
},
relations: {
accessToken: true,
},
}), }),
]); ]);
if (app == null) { if (app == null) {
@ -75,20 +80,14 @@ export async function oauth(ctx: Koa.Context): void {
return; return;
} }
const [ token ] = await Promise.all([ // session is single use
AccessTokens.findOneByOrFail({ await AuthSessions.delete(session.id),
appId: client_id,
userId: session.userId,
}),
// session is single use
AuthSessions.delete(session.id),
]);
ctx.response.status = 200; ctx.response.status = 200;
ctx.response.body = { ctx.response.body = {
access_token: token.token, access_token: session.accessToken.token,
token_type: 'bearer', token_type: 'bearer',
// FIXME: per-token permissions scope: session.accessToken.permission.join(' '),
scope: app.permission.join(' '),
}; };
}; };

View file

@ -2,6 +2,7 @@ import * as crypto from 'node:crypto';
import { AuthSessions, AccessTokens, Apps } from '@/models/index.js'; import { AuthSessions, AccessTokens, Apps } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js'; import { genId } from '@/misc/gen-id.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { kinds } from '@/misc/api-permissions.js';
import define from '../../define.js'; import define from '../../define.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
@ -19,6 +20,17 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
token: { type: 'string' }, token: { type: 'string' },
permission: {
description: 'The permissions which the user wishes to grant in this token. '
+ 'Permissions that the app has not registered before will be removed. '
+ 'Defaults to all permissions the app was registered with if not provided.',
type: 'array',
uniqueItems: true,
items: {
type: 'string',
enum: kinds,
},
},
}, },
required: ['token'], required: ['token'],
} as const; } as const;
@ -34,37 +46,35 @@ export default define(meta, paramDef, async (ps, user) => {
// Generate access token // Generate access token
const accessToken = secureRndstr(32, true); const accessToken = secureRndstr(32, true);
// Fetch exist access token // Check for existing access token.
const exist = await AccessTokens.findOneBy({ const app = await Apps.findOneByOrFail({ id: session.appId });
// Generate Hash
const sha256 = crypto.createHash('sha256');
sha256.update(accessToken + app.secret);
const hash = sha256.digest('hex');
const now = new Date();
// Calculate the set intersection between requested permissions and
// permissions that the app registered with. If no specific permissions
// are given, grant all permissions the app registered with.
const permission = ps.permission?.filter(x => app.permission.includes(x)) ?? app.permission;
const accessTokenId = genId();
// Insert access token doc
await AccessTokens.insert({
id: accessTokenId,
createdAt: now,
lastUsedAt: now,
appId: session.appId, appId: session.appId,
userId: user.id, userId: user.id,
token: accessToken,
hash,
permission,
}); });
if (exist == null) {
// Lookup app
const app = await Apps.findOneByOrFail({ id: session.appId });
// Generate Hash
const sha256 = crypto.createHash('sha256');
sha256.update(accessToken + app.secret);
const hash = sha256.digest('hex');
const now = new Date();
// Insert access token doc
await AccessTokens.insert({
id: genId(),
createdAt: now,
lastUsedAt: now,
appId: session.appId,
userId: user.id,
token: accessToken,
hash,
});
}
// Update session // Update session
await AuthSessions.update(session.id, { await AuthSessions.update(session.id, { accessTokenId });
userId: user.id,
});
}); });

View file

@ -46,27 +46,26 @@ export default define(meta, paramDef, async (ps) => {
if (app == null) throw new ApiError('NO_SUCH_APP'); if (app == null) throw new ApiError('NO_SUCH_APP');
// Fetch token // Fetch token
const session = await AuthSessions.findOneBy({ const session = await AuthSessions.findOne({
token: ps.token, where: {
appId: app.id, token: ps.token,
appId: app.id,
},
relations: {
accessToken: true,
},
}); });
if (session == null) throw new ApiError('NO_SUCH_SESSION'); if (session == null) throw new ApiError('NO_SUCH_SESSION');
if (session.userId == null) throw new ApiError('PENDING_SESSION'); if (session.accessTokenId == null) throw new ApiError('PENDING_SESSION');
// Lookup access token
const accessToken = await AccessTokens.findOneByOrFail({
appId: app.id,
userId: session.userId,
});
// Delete session // Delete session
AuthSessions.delete(session.id); AuthSessions.delete(session.id);
return { return {
accessToken: accessToken.token, accessToken: session.accessToken.token,
user: await Users.pack(session.userId, null, { user: await Users.pack(session.accessToken.userId, null, {
detail: true, detail: true,
}), }),
}; };

View file

@ -7,8 +7,8 @@
</div> </div>
<div class="_content"> <div class="_content">
<h2>{{ i18n.ts._auth.permissionAsk }}</h2> <h2>{{ i18n.ts._auth.permissionAsk }}</h2>
<ul v-if="app.permission.length > 0"> <ul v-if="permission.length > 0">
<li v-for="p in app.permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li> <li v-for="p in permission" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
</ul> </ul>
<template v-else> <template v-else>
{{ i18n.ts.noPermissionRequested }} {{ i18n.ts.noPermissionRequested }}
@ -32,12 +32,12 @@ const emit = defineEmits<{
}>(); }>();
const props = defineProps<{ const props = defineProps<{
// TODO: allow user to deselect some permissions
permission: string[];
session: { session: {
app: { app: {
name: string; name: string;
id: string;
description: string; description: string;
permission: string[];
}; };
token: string; token: string;
}; };
@ -56,6 +56,7 @@ function cancel(): void {
function accept(): void { function accept(): void {
os.api('auth/accept', { os.api('auth/accept', {
token: props.session.token, token: props.session.token,
permission: props.permission,
}).then(() => { }).then(() => {
emit('accepted'); emit('accepted');
}); });

View file

@ -9,6 +9,7 @@
ref="form" ref="form"
class="form" class="form"
:session="session" :session="session"
:permission="permission"
@denied="denied" @denied="denied"
@accepted="accepted" @accepted="accepted"
/> />
@ -50,6 +51,7 @@ const props = defineProps<{
let state: 'fetching' | 'waiting' | 'denied' | 'accepted' | 'fetch-session-error' | 'oauth-error' = $ref('fetching'); let state: 'fetching' | 'waiting' | 'denied' | 'accepted' | 'fetch-session-error' | 'oauth-error' = $ref('fetching');
let session = $ref(null); let session = $ref(null);
let permission: string[] = $ref([]);
// if this is an OAuth request, will contain the respective parameters // if this is an OAuth request, will contain the respective parameters
let oauth: { state: string | null, callback: string } | null = null; let oauth: { state: string | null, callback: string } | null = null;
@ -95,6 +97,16 @@ onMounted(async () => {
state: params.get('state'), state: params.get('state'),
callback: params.get('redirect_uri') ?? session.app.callbackUrl, callback: params.get('redirect_uri') ?? session.app.callbackUrl,
}; };
if (params.has('scope')) {
// If there are specific permissions requested, they have to be a subset of the apps permissions.
permission = params.get('scope')
.split(' ')
.filter(scope => session.app.permission.includes(scope));
} else {
// Default to all permissions of this app.
permission = session.app.permission;
}
} else if (!props.token) { } else if (!props.token) {
state = 'fetch-session-error'; state = 'fetch-session-error';
} else { } else {
@ -103,6 +115,7 @@ onMounted(async () => {
}).catch(() => { }).catch(() => {
state = 'fetch-session-error'; state = 'fetch-session-error';
}); });
permission = session?.app.permission ?? [];
} }
// abort if an error occurred // abort if an error occurred
@ -113,6 +126,7 @@ onMounted(async () => {
// already authorized, move on through! // already authorized, move on through!
os.api('auth/accept', { os.api('auth/accept', {
token: session.token, token: session.token,
permission,
}).then(() => { }).then(() => {
accepted(); accepted();
}); });