mirror of
https://iceshrimp.dev/limepotato/jormungandr-bite.git
synced 2024-11-22 01:47:39 -07:00
[backend/web-api] Add basic auth endpoints and a bunch of other things
This commit is contained in:
parent
b9c86d0d4c
commit
1870dc33b5
15 changed files with 183 additions and 51 deletions
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
@ -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") `);
|
||||
}
|
||||
}
|
36
packages/backend/src/models/entities/session.ts
Normal file
36
packages/backend/src/models/entities/session.ts
Normal 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;
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"] });
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
3
packages/backend/src/server/api/web/misc/decorators.ts
Normal file
3
packages/backend/src/server/api/web/misc/decorators.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { State } from "@iceshrimp/koa-openapi";
|
||||
|
||||
export const CurrentSession = ()=>State('session');
|
16
packages/backend/src/server/api/web/misc/koa.ts
Normal file
16
packages/backend/src/server/api/web/misc/koa.ts
Normal 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;
|
||||
}
|
Loading…
Reference in a new issue