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
parent 181a2c4217
commit cc4e9f9071
Signed by untrusted user: Johann150
GPG key ID: 9EE6577A2A06F8F1
8 changed files with 116 additions and 79 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 { User } from './user.js';
import { AccessToken } from './access-token.js';
import { App } from './app.js';
@Entity()
@ -23,19 +23,19 @@ export class AuthSession {
...id(),
nullable: true,
})
public userId: User['id'] | null;
public accessTokenId: AccessToken['id'] | null;
@ManyToOne(type => User, {
@ManyToOne(() => AccessToken, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn()
public user: User | null;
public accessToken: AccessToken | null;
@Column(id())
public appId: App['id'];
@ManyToOne(type => App, {
@ManyToOne(() => App, {
onDelete: 'CASCADE',
})
@JoinColumn()

View file

@ -6,8 +6,6 @@ import { App } from '@/models/entities/app.js';
import { localUserByIdCache, localUserByNativeTokenCache } from '@/services/user-cache.js';
import isNativeToken from './common/is-native-token.js';
const appCache = new Cache<App>(Infinity);
export class AuthenticationError extends Error {
constructor(message: string) {
super(message);
@ -69,16 +67,6 @@ export default async (authorization: string | null | undefined, bodyToken: strin
id: accessToken.userId,
}) as Promise<ILocalUser>);
if (accessToken.appId) {
const app = await appCache.fetch(accessToken.appId,
() => Apps.findOneByOrFail({ id: accessToken.appId! }));
return [user, {
id: accessToken.id,
permission: app.permission,
} as AccessToken];
} else {
return [user, accessToken];
}
return [user, accessToken];
}
};

View file

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

View file

@ -2,6 +2,7 @@ import * as crypto from 'node:crypto';
import { AuthSessions, AccessTokens, Apps } from '@/models/index.js';
import { genId } from '@/misc/gen-id.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { kinds } from '@/misc/api-permissions.js';
import define from '../../define.js';
import { ApiError } from '../../error.js';
@ -19,6 +20,17 @@ export const paramDef = {
type: 'object',
properties: {
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'],
} as const;
@ -34,37 +46,35 @@ export default define(meta, paramDef, async (ps, user) => {
// Generate access token
const accessToken = secureRndstr(32, true);
// Fetch exist access token
const exist = await AccessTokens.findOneBy({
// Check for existing access token.
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,
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
await AuthSessions.update(session.id, {
userId: user.id,
});
await AuthSessions.update(session.id, { accessTokenId });
});

View file

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

View file

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

View file

@ -9,6 +9,7 @@
ref="form"
class="form"
:session="session"
:permission="permission"
@denied="denied"
@accepted="accepted"
/>
@ -50,6 +51,7 @@ const props = defineProps<{
let state: 'fetching' | 'waiting' | 'denied' | 'accepted' | 'fetch-session-error' | 'oauth-error' = $ref('fetching');
let session = $ref(null);
let permission: string[] = $ref([]);
// if this is an OAuth request, will contain the respective parameters
let oauth: { state: string | null, callback: string } | null = null;
@ -95,6 +97,16 @@ onMounted(async () => {
state: params.get('state'),
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) {
state = 'fetch-session-error';
} else {
@ -103,6 +115,7 @@ onMounted(async () => {
}).catch(() => {
state = 'fetch-session-error';
});
permission = session?.app.permission ?? [];
}
// abort if an error occurred
@ -113,6 +126,7 @@ onMounted(async () => {
// already authorized, move on through!
os.api('auth/accept', {
token: session.token,
permission,
}).then(() => {
accepted();
});