From 1870dc33b5b7ab93d674d9bed03a47fc5394df49 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Mon, 11 Dec 2023 22:40:52 +0100 Subject: [PATCH] [backend/web-api] Add basic auth endpoints and a bunch of other things --- packages/backend/src/db/postgre.ts | 2 + .../1702326649645-add-session-table.ts | 21 +++++++++ .../backend/src/models/entities/session.ts | 36 +++++++++++++++ packages/backend/src/models/index.ts | 2 + .../server/api/web/controllers/_template.ts | 14 ------ .../src/server/api/web/controllers/auth.ts | 44 +++++++++++++++++-- .../src/server/api/web/entities/auth.ts | 7 +++ .../src/server/api/web/entities/note.ts | 5 ++- .../src/server/api/web/handlers/note.ts | 3 +- .../src/server/api/web/handlers/user.ts | 2 +- packages/backend/src/server/api/web/index.ts | 17 ++----- .../src/server/api/web/middleware/auth.ts | 26 ++++++----- .../server/api/web/middleware/rate-limit.ts | 36 ++++++++++++--- .../src/server/api/web/misc/decorators.ts | 3 ++ .../backend/src/server/api/web/misc/koa.ts | 16 +++++++ 15 files changed, 183 insertions(+), 51 deletions(-) create mode 100644 packages/backend/src/migration/1702326649645-add-session-table.ts create mode 100644 packages/backend/src/models/entities/session.ts delete mode 100644 packages/backend/src/server/api/web/controllers/_template.ts create mode 100644 packages/backend/src/server/api/web/misc/decorators.ts create mode 100644 packages/backend/src/server/api/web/misc/koa.ts diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index 3e3f3a289..cbb62faeb 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -76,6 +76,7 @@ import { OAuthApp } from "@/models/entities/oauth-app.js"; import { OAuthToken } from "@/models/entities/oauth-token.js"; import { HtmlNoteCacheEntry } from "@/models/entities/html-note-cache-entry.js"; import { HtmlUserCacheEntry } from "@/models/entities/html-user-cache-entry.js"; +import { Session } from "@/models/entities/session.js"; const sqlLogger = dbLogger.createSubLogger("sql", "gray", false); class MyCustomLogger implements Logger { @@ -179,6 +180,7 @@ export const entities = [ OAuthToken, HtmlNoteCacheEntry, HtmlUserCacheEntry, + Session, ...charts, ]; diff --git a/packages/backend/src/migration/1702326649645-add-session-table.ts b/packages/backend/src/migration/1702326649645-add-session-table.ts new file mode 100644 index 000000000..d5ed0fef1 --- /dev/null +++ b/packages/backend/src/migration/1702326649645-add-session-table.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSessionTable1702326649645 implements MigrationInterface { + name = 'AddSessionTable1702326649645' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_0ee0c7254e5612a8129251997e"`); + await queryRunner.query(`CREATE TABLE "session" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "token" character varying(64) NOT NULL, "active" boolean NOT NULL, CONSTRAINT "PK_f55da76ac1c3ac420f444d2ff11" PRIMARY KEY ("id")); COMMENT ON COLUMN "session"."createdAt" IS 'The created date of the OAuth token'; COMMENT ON COLUMN "session"."token" IS 'The authorization token'; COMMENT ON COLUMN "session"."active" IS 'Whether or not the token has been activated (i.e. 2fa has been confirmed)'`); + await queryRunner.query(`CREATE INDEX "IDX_232f8e85d7633bd6ddfad42169" ON "session" ("token") `); + await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "mastoId"`); + await queryRunner.query(`ALTER TABLE "session" ADD CONSTRAINT "FK_3d2f174ef04fb312fdebd0ddc53" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "session" DROP CONSTRAINT "FK_3d2f174ef04fb312fdebd0ddc53"`); + await queryRunner.query(`ALTER TABLE "notification" ADD "mastoId" SERIAL NOT NULL`); + await queryRunner.query(`DROP INDEX "public"."IDX_232f8e85d7633bd6ddfad42169"`); + await queryRunner.query(`DROP TABLE "session"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0ee0c7254e5612a8129251997e" ON "notification" ("mastoId") `); + } +} diff --git a/packages/backend/src/models/entities/session.ts b/packages/backend/src/models/entities/session.ts new file mode 100644 index 000000000..4e618c6bc --- /dev/null +++ b/packages/backend/src/models/entities/session.ts @@ -0,0 +1,36 @@ +import { Entity, PrimaryColumn, Column, Index, ManyToOne, JoinColumn } from "typeorm"; +import { id } from "../id.js"; +import { OAuthApp } from "@/models/entities/oauth-app.js"; +import { User } from "@/models/entities/user.js"; + +@Entity('session') +export class Session { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the OAuth token", + }) + public createdAt: Date; + + @Column(id()) + public userId: User["id"]; + + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User; + + @Index() + @Column("varchar", { + length: 64, + comment: "The authorization token", + }) + public token: string; + + @Column("boolean", { + comment: "Whether or not the token has been activated (i.e. 2fa has been confirmed)", + }) + public active: boolean; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 2f229689b..56d393a68 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -70,6 +70,7 @@ import { OAuthToken } from "@/models/entities/oauth-token.js"; import { UserProfileRepository } from "@/models/repositories/user-profile.js"; import { HtmlNoteCacheEntry } from "@/models/entities/html-note-cache-entry.js"; import { HtmlUserCacheEntry } from "@/models/entities/html-user-cache-entry.js"; +import { Session } from "@/models/entities/session.js"; export const Announcements = db.getRepository(Announcement); export const AnnouncementReads = db.getRepository(AnnouncementRead); @@ -138,3 +139,4 @@ export const OAuthApps = db.getRepository(OAuthApp); export const OAuthTokens = db.getRepository(OAuthToken); export const HtmlUserCacheEntries = db.getRepository(HtmlUserCacheEntry); export const HtmlNoteCacheEntries = db.getRepository(HtmlNoteCacheEntry); +export const Sessions = db.getRepository(Session); diff --git a/packages/backend/src/server/api/web/controllers/_template.ts b/packages/backend/src/server/api/web/controllers/_template.ts deleted file mode 100644 index 555e99242..000000000 --- a/packages/backend/src/server/api/web/controllers/_template.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Controller, Get, CurrentUser, Params, } from "@iceshrimp/koa-openapi"; -import type { ILocalUser } from "@/models/entities/user.js"; -import { NoteHandler } from "@/server/api/web/handlers/note.js"; - -@Controller('/note') -export class NoteController { - @Get('/:id') - async getNote( - @CurrentUser() me: ILocalUser | null, - @Params('id') id: string, - ) { - NoteHandler.getNoteOrFail(me, id); - } -} diff --git a/packages/backend/src/server/api/web/controllers/auth.ts b/packages/backend/src/server/api/web/controllers/auth.ts index cc4704c34..162a8881c 100644 --- a/packages/backend/src/server/api/web/controllers/auth.ts +++ b/packages/backend/src/server/api/web/controllers/auth.ts @@ -1,18 +1,56 @@ -import { Controller, CurrentUser, Get } from "@iceshrimp/koa-openapi"; +import { Controller, Get, Post, Body, CurrentUser, Flow } from "@iceshrimp/koa-openapi"; import type { ILocalUser } from "@/models/entities/user.js"; import { UserHandler } from "@/server/api/web/handlers/user.js"; -import { AuthResponse } from "@/server/api/web/entities/auth.js"; +import type { AuthRequest, AuthResponse } from "@/server/api/web/entities/auth.js"; +import type { Session } from "@/models/entities/session.js"; +import { RatelimitRouteMiddleware } from "@/server/api/web/middleware/rate-limit.js"; +import { CurrentSession } from "@/server/api/web/misc/decorators.js"; +import { Sessions, UserProfiles, Users } from "@/models/index.js"; +import { unauthorized, badRequest } from "@hapi/boom"; +import { comparePassword } from "@/misc/password.js"; +import { IsNull } from "typeorm"; +import { genId } from "@/misc/gen-id.js"; +import { secureRndstr } from "@/misc/secure-rndstr.js"; @Controller('/auth') export class AuthController { @Get('/') async getAuth( @CurrentUser() me: ILocalUser | null, + @CurrentSession() session: Session | null, ): Promise { const user = me ? await UserHandler.getUser(me, me.id) : null; return { - authenticated: !!me, + authenticated: !!session?.active, + status: user && session?.active ? null : '2fa', + token: session?.token ?? null, user: user, }; } + + @Post('/') + @Flow([RatelimitRouteMiddleware("auth", 10, 60000, true)]) + async login(@Body({ required: true }) request: AuthRequest): Promise { + if (request.username == null || request.password == null) throw badRequest(); + + const user = await Users.findOneBy({ usernameLower: request.username.toLowerCase(), host: IsNull() }); + if (!user) throw unauthorized(); + + const profile = await UserProfiles.findOneBy( { userId: user.id }); + if (!profile || profile.password == null) throw unauthorized(); + + if (!await comparePassword(request.password, profile.password)) throw unauthorized(); + + const result = await Sessions.insert({ + id: genId(), + createdAt: new Date(), + active: !profile.twoFactorEnabled, + userId: user.id, + token: secureRndstr(32), + }); + + const session = await Sessions.findOneByOrFail(result.identifiers[0]); + + return this.getAuth(user as ILocalUser, session); + } } diff --git a/packages/backend/src/server/api/web/entities/auth.ts b/packages/backend/src/server/api/web/entities/auth.ts index 339263040..64fef2dd9 100644 --- a/packages/backend/src/server/api/web/entities/auth.ts +++ b/packages/backend/src/server/api/web/entities/auth.ts @@ -2,5 +2,12 @@ import { UserResponse } from "@/server/api/web/entities/user.js"; export type AuthResponse = { authenticated: boolean; + status: null | '2fa'; + token: string | null; user: UserResponse | null; } + +export type AuthRequest = { + username: string; + password: string; +} diff --git a/packages/backend/src/server/api/web/entities/note.ts b/packages/backend/src/server/api/web/entities/note.ts index dcbca7820..449738e56 100644 --- a/packages/backend/src/server/api/web/entities/note.ts +++ b/packages/backend/src/server/api/web/entities/note.ts @@ -6,10 +6,11 @@ export type NoteResponse = { text: string | null; user: UserResponse; reply: NoteResponse | undefined | null; // Undefined if no record, null if not visible - renote: NoteResponse | undefined | null; // Undefined if no record, null if not visible + renote: NoteResponse | undefined | null; // Undefined if no record, null if not visible + quote: NoteResponse | undefined | null; // Undefined if no record, null if not visible }; export type TimelineResponse = { notes: NoteResponse[]; - pagination: {}; //TODO + limit: number; }; diff --git a/packages/backend/src/server/api/web/handlers/note.ts b/packages/backend/src/server/api/web/handlers/note.ts index d0870509b..fd508b983 100644 --- a/packages/backend/src/server/api/web/handlers/note.ts +++ b/packages/backend/src/server/api/web/handlers/note.ts @@ -24,7 +24,8 @@ export class NoteHandler { id: note.id, text: note.text, user: note.user ? await UserHandler.encode(note.user, me) : await UserHandler.getUser(me, note.userId), - renote: note.renoteId && recurse > 0 ? await this.encode(note.renote ?? await this.getNoteOrFail(note.renoteId), me, isQuote(note) ? --recurse : 0) : undefined, + renote: !isQuote(note) && note.renoteId && recurse > 0 ? await this.encode(note.renote ?? await this.getNoteOrFail(note.renoteId), me, 0) : undefined, + quote: isQuote(note) && note.renoteId && recurse > 0 ? await this.encode(note.renote ?? await this.getNoteOrFail(note.renoteId), me, --recurse) : undefined, reply: note.replyId && recurse > 0 ? await this.encode(note.renote ?? await this.getNoteOrFail(note.replyId), me, 0) : undefined, }; } diff --git a/packages/backend/src/server/api/web/handlers/user.ts b/packages/backend/src/server/api/web/handlers/user.ts index 2b537a7b8..fdbdfbd85 100644 --- a/packages/backend/src/server/api/web/handlers/user.ts +++ b/packages/backend/src/server/api/web/handlers/user.ts @@ -35,7 +35,7 @@ export class UserHandler { const result = query.take(Math.min(limit, 100)).getMany(); return { notes: await NoteHandler.encodeMany(await result, me), - pagination: {}, + limit: limit } } diff --git a/packages/backend/src/server/api/web/index.ts b/packages/backend/src/server/api/web/index.ts index d5dcf215f..9ddc2ca59 100644 --- a/packages/backend/src/server/api/web/index.ts +++ b/packages/backend/src/server/api/web/index.ts @@ -1,24 +1,13 @@ import Router from "@koa/router"; -import Koa, { DefaultState, Context, Middleware } from "koa"; -import { bootstrapControllers, Ctx } from "@iceshrimp/koa-openapi"; -import { ILocalUser } from "@/models/entities/user.js"; -import { AccessToken } from "@/models/entities/access-token.js"; +import Koa, { DefaultState } from "koa"; +import { bootstrapControllers } from "@iceshrimp/koa-openapi"; import { UserController } from "@/server/api/web/controllers/user.js"; import { RatelimitMiddleware } from "@/server/api/web/middleware/rate-limit.js"; import { AuthenticationMiddleware } from "@/server/api/web/middleware/auth.js"; import { ErrorHandlingMiddleware } from "@/server/api/web/middleware/error-handling.js"; import { AuthController } from "@/server/api/web/controllers/auth.js"; import { NoteController } from "@/server/api/web/controllers/note.js"; - -export type WebRouter = Router; -export type WebMiddleware = Middleware; - -export interface WebState extends DefaultState {} - -export interface WebContext extends Context { - user: ILocalUser | null; - token: AccessToken | null; -} +import { WebContext, WebRouter } from "@/server/api/web/misc/koa.js"; export class WebAPI { private readonly router: WebRouter; diff --git a/packages/backend/src/server/api/web/middleware/auth.ts b/packages/backend/src/server/api/web/middleware/auth.ts index 278f3e75d..85ac1b63e 100644 --- a/packages/backend/src/server/api/web/middleware/auth.ts +++ b/packages/backend/src/server/api/web/middleware/auth.ts @@ -1,16 +1,13 @@ -import { WebMiddleware, WebContext, WebState } from "@/server/api/web/index.js"; +import { WebContext, WebMiddleware } from "@/server/api/web/misc/koa.js"; import { Next } from "koa"; -import authenticate from "@/server/api/authenticate.js"; +import { Sessions } from "@/models/index.js"; +import { Session } from "@/models/entities/session.js"; +import { ILocalUser } from "@/models/entities/user.js"; export const AuthenticationMiddleware: WebMiddleware = async (ctx: WebContext, next: Next) => { - try { - const [ user, token ] = await authenticate(ctx.headers.authorization, null, false); - - //FIXME we shouldn't need to cast this - (ctx.state as WebState).user = user ?? null; - (ctx.state as WebState).token = token ?? null; - - } catch {} + const session = await authenticate(ctx.headers.authorization); + ctx.state.user = session?.user as ILocalUser; + ctx.state.session = session; await next(); } @@ -18,7 +15,7 @@ export const AuthenticationMiddleware: WebMiddleware = async (ctx: WebContext, n export function AuthorizationMiddleware(required: boolean, scopes: string[] = []): WebMiddleware { return async (ctx: WebContext, next: Next) => { try { - if (required && !(ctx.state as WebState).user) { + if (required && !ctx.state.session?.active) { throw new Error(); //FIXME } } catch {} @@ -26,3 +23,10 @@ export function AuthorizationMiddleware(required: boolean, scopes: string[] = [] await next(); } } + +async function authenticate(token: string | undefined): Promise { + if (token == null || token.length < 1) return null; + if (token.toLowerCase().startsWith('bearer ')) token = token.substring(7); + + return Sessions.findOne({ where: { token }, relations: ["user"] }); +} diff --git a/packages/backend/src/server/api/web/middleware/rate-limit.ts b/packages/backend/src/server/api/web/middleware/rate-limit.ts index 5abb86b92..55a86878f 100644 --- a/packages/backend/src/server/api/web/middleware/rate-limit.ts +++ b/packages/backend/src/server/api/web/middleware/rate-limit.ts @@ -1,10 +1,10 @@ import koaRatelimit from "koa-ratelimit"; -import { WebContext, WebMiddleware } from "@/server/api/web/index.js"; +import { WebContext, WebMiddleware } from "@/server/api/web/misc/koa.js"; import { Next } from "koa"; import { redisClient } from "@/db/redis.js"; import { tooManyRequests } from "@hapi/boom"; -export const RatelimitMiddleware: WebMiddleware = async (ctx: WebContext, next: Next) => { +export async function RatelimitMiddleware(ctx: WebContext, next: Next) { // We can't assign limiter directly if we want to preserve type hints for WebContext and WebState //TODO: server config options (disable limiter entirely, set max/duration, set different rate limits for auth/noauth, bypass rate limit for admins) const limiter = koaRatelimit({ @@ -23,10 +23,36 @@ export const RatelimitMiddleware: WebMiddleware = async (ctx: WebContext, next: try { await limiter(ctx, next); - } - catch (e: any) { + } catch (e: any) { if (e.name === 'TooManyRequestsError') throw tooManyRequests(e.message); throw e; } -}; +} + +export function RatelimitRouteMiddleware(prefix: string, max: number = 500, duration: number = 60000, ipOnly: boolean = false): WebMiddleware { + return async (ctx: WebContext, next: Next) => { + const limiter = koaRatelimit({ + driver: "redis", + db: redisClient, + max: max, + duration: duration, + id: () => `${prefix}-${ipOnly ? ctx.request.ip : ctx.state.user?.id ?? ctx.request.ip}`, + headers: { + remaining: 'X-RateLimit-Remaining', + total: 'X-RateLimit-Limit', + reset: 'X-RateLimit-Reset', + }, + throw: true, + }); + + try { + await limiter(ctx, next); + } + catch (e: any) { + if (e.name === 'TooManyRequestsError') + throw tooManyRequests(e.message); + throw e; + } + } +} diff --git a/packages/backend/src/server/api/web/misc/decorators.ts b/packages/backend/src/server/api/web/misc/decorators.ts new file mode 100644 index 000000000..9c927c87f --- /dev/null +++ b/packages/backend/src/server/api/web/misc/decorators.ts @@ -0,0 +1,3 @@ +import { State } from "@iceshrimp/koa-openapi"; + +export const CurrentSession = ()=>State('session'); diff --git a/packages/backend/src/server/api/web/misc/koa.ts b/packages/backend/src/server/api/web/misc/koa.ts new file mode 100644 index 000000000..e86c3bdbc --- /dev/null +++ b/packages/backend/src/server/api/web/misc/koa.ts @@ -0,0 +1,16 @@ +import { ILocalUser } from "@/models/entities/user.js"; +import { Session } from "@/models/entities/session.js"; +import Router from "@koa/router"; +import { Context, DefaultState, Middleware } from "koa"; + +export type WebRouter = Router; +export type WebMiddleware = Middleware; + +export interface WebState extends DefaultState { + user: ILocalUser | null; + session: Session | null; +} + +export interface WebContext extends Context { + state: WebState; +}