[backend/web-api] Add basic auth endpoints and a bunch of other things

This commit is contained in:
Laura Hausmann 2023-12-11 22:40:52 +01:00
parent b9c86d0d4c
commit 1870dc33b5
No known key found for this signature in database
GPG Key ID: D044E84C5BE01605
15 changed files with 183 additions and 51 deletions

View File

@ -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,
];

View File

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddSessionTable1702326649645 implements MigrationInterface {
name = 'AddSessionTable1702326649645'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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") `);
}
}

View File

@ -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;
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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<AuthResponse> {
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<AuthResponse> {
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);
}
}

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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,
};
}

View File

@ -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
}
}

View File

@ -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<WebState, WebContext>;
export type WebMiddleware = Middleware<WebState, WebContext>;
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;

View File

@ -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<Session | null> {
if (token == null || token.length < 1) return null;
if (token.toLowerCase().startsWith('bearer ')) token = token.substring(7);
return Sessions.findOne({ where: { token }, relations: ["user"] });
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,3 @@
import { State } from "@iceshrimp/koa-openapi";
export const CurrentSession = ()=>State('session');

View File

@ -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<WebState, WebContext>;
export type WebMiddleware = Middleware<WebState, WebContext>;
export interface WebState extends DefaultState {
user: ILocalUser | null;
session: Session | null;
}
export interface WebContext extends Context {
state: WebState;
}