forked from FoundKeyGang/FoundKey
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:
parent
2f2e6a58a4
commit
79e3c20189
8 changed files with 118 additions and 85 deletions
|
@ -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`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
|
|
|
@ -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];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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(' '),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue