feat(api): 🎨 Allow login with either username or email

This commit is contained in:
Jesse Wierzbinski 2024-05-08 08:02:05 +00:00
parent 47c88dd7dd
commit f9c9a7d527
No known key found for this signature in database
7 changed files with 260 additions and 62 deletions

View file

@ -0,0 +1,230 @@
import { afterAll, describe, expect, test } from "bun:test";
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { db } from "~drizzle/db";
import { Applications } from "~drizzle/schema";
import { config } from "~packages/config-manager";
import {
deleteOldTestUsers,
getTestUsers,
sendTestRequest,
} from "~tests/utils";
import { meta } from "./index";
await deleteOldTestUsers();
const { users, deleteUsers, passwords } = await getTestUsers(1);
// Create application
const application = (
await db
.insert(Applications)
.values({
name: "Test Application",
clientId: randomBytes(32).toString("hex"),
secret: "test",
redirectUri: "https://example.com",
scopes: "read write",
})
.returning()
)[0];
afterAll(async () => {
await deleteUsers();
await db.delete(Applications).where(eq(Applications.id, application.id));
});
// /api/auth/login
describe(meta.route, () => {
test("should get a JWT with email", async () => {
const formData = new FormData();
formData.append("identifier", users[0]?.getUser().email ?? "");
formData.append("password", passwords[0]);
const response = await sendTestRequest(
new Request(
new URL(
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
config.http.base_url,
),
{
method: "POST",
body: formData,
},
),
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
expect(locationHeader.pathname).toBe("/oauth/consent");
expect(locationHeader.searchParams.get("client_id")).toBe(
application.clientId,
);
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
"https://example.com",
);
expect(locationHeader.searchParams.get("response_type")).toBe("code");
expect(locationHeader.searchParams.get("scope")).toBe("read write");
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
});
test("should get a JWT with username", async () => {
const formData = new FormData();
formData.append("identifier", users[0]?.getUser().username ?? "");
formData.append("password", passwords[0]);
const response = await sendTestRequest(
new Request(
new URL(
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
config.http.base_url,
),
{
method: "POST",
body: formData,
},
),
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
expect(locationHeader.pathname).toBe("/oauth/consent");
expect(locationHeader.searchParams.get("client_id")).toBe(
application.clientId,
);
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
"https://example.com",
);
expect(locationHeader.searchParams.get("response_type")).toBe("code");
expect(locationHeader.searchParams.get("scope")).toBe("read write");
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
});
describe("should reject invalid credentials", async () => {
// Redirects to /oauth/authorize on invalid
test("invalid email", async () => {
const formData = new FormData();
formData.append("identifier", "ababa@gmail.com");
formData.append("password", "password");
const response = await sendTestRequest(
new Request(
new URL(
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
config.http.base_url,
),
{
method: "POST",
body: formData,
},
),
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
expect(locationHeader.pathname).toBe("/oauth/authorize");
expect(locationHeader.searchParams.get("error")).toBe(
"invalid_grant",
);
expect(locationHeader.searchParams.get("error_description")).toBe(
"Invalid email or password",
);
expect(response.headers.get("Set-Cookie")).toBeNull();
});
test("invalid username", async () => {
const formData = new FormData();
formData.append("identifier", "ababa");
formData.append("password", "password");
const response = await sendTestRequest(
new Request(
new URL(
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
config.http.base_url,
),
{
method: "POST",
body: formData,
},
),
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
expect(locationHeader.pathname).toBe("/oauth/authorize");
expect(locationHeader.searchParams.get("error")).toBe(
"invalid_grant",
);
expect(locationHeader.searchParams.get("error_description")).toBe(
"Invalid email or password",
);
expect(response.headers.get("Set-Cookie")).toBeNull();
});
test("invalid password", async () => {
const formData = new FormData();
formData.append("identifier", users[0]?.getUser().email ?? "");
formData.append("password", "password");
const response = await sendTestRequest(
new Request(
new URL(
`/api/auth/login?client_id=${application.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
config.http.base_url,
),
{
method: "POST",
body: formData,
},
),
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
expect(locationHeader.pathname).toBe("/oauth/authorize");
expect(locationHeader.searchParams.get("error")).toBe(
"invalid_grant",
);
expect(locationHeader.searchParams.get("error_description")).toBe(
"Invalid email or password",
);
expect(response.headers.get("Set-Cookie")).toBeNull();
});
});
});

View file

@ -1,7 +1,7 @@
import { applyConfig, handleZodError } from "@api";
import { zValidator } from "@hono/zod-validator";
import { errorResponse, response } from "@response";
import { eq } from "drizzle-orm";
import { eq, or } from "drizzle-orm";
import type { Hono } from "hono";
import { SignJWT } from "jose";
import { z } from "zod";
@ -24,7 +24,11 @@ export const meta = applyConfig({
export const schemas = {
form: z.object({
email: z.string().email().toLowerCase(),
identifier: z
.string()
.email()
.toLowerCase()
.or(z.string().toLowerCase()),
password: z.string().min(2).max(100),
}),
query: z.object({
@ -69,7 +73,10 @@ const returnError = (query: object, error: string, description: string) => {
searchParams.append("error_description", description);
return response(null, 302, {
Location: `/oauth/authorize?${searchParams.toString()}`,
Location: new URL(
`/oauth/authorize?${searchParams.toString()}`,
config.http.base_url,
).toString(),
});
};
@ -80,12 +87,15 @@ export default (app: Hono) =>
zValidator("form", schemas.form, handleZodError),
zValidator("query", schemas.query, handleZodError),
async (context) => {
const { email, password } = context.req.valid("form");
const { identifier, password } = context.req.valid("form");
const { client_id } = context.req.valid("query");
// Find user
const user = await User.fromSql(
eq(Users.email, email.toLowerCase()),
or(
eq(Users.email, identifier.toLowerCase()),
eq(Users.username, identifier.toLowerCase()),
),
);
if (
@ -97,7 +107,7 @@ export default (app: Hono) =>
)
return returnError(
context.req.query(),
"invalid_request",
"invalid_grant",
"Invalid email or password",
);

View file

@ -19,12 +19,6 @@ afterAll(async () => {
await deleteUsers();
});
const getFormData = (object: Record<string, string | number | boolean>) =>
Object.keys(object).reduce((formData, key) => {
formData.append(key, String(object[key]));
return formData;
}, new FormData());
beforeAll(async () => {
const response = await sendTestRequest(
new Request(

View file

@ -1,3 +1,6 @@
/**
* @deprecated
*/
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { config } from "config-manager";
import { eq } from "drizzle-orm";

View file

@ -1,9 +1,11 @@
/**
* @deprecated
*/
import { afterAll, describe, expect, test } from "bun:test";
import { config } from "config-manager";
import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "~tests/utils";
import type { Account as APIAccount } from "~types/mastodon/account";
import type { Relationship as APIRelationship } from "~types/mastodon/relationship";
import type { Status as APIStatus } from "~types/mastodon/status";
const base_url = config.http.base_url;
@ -105,33 +107,6 @@ describe("API Tests", () => {
});
});
describe("GET /api/v1/accounts/:id/statuses", () => {
test("should return the statuses of the specified user", async () => {
const response = await sendTestRequest(
new Request(
wrapRelativeUrl(
`/api/v1/accounts/${user.id}/statuses`,
base_url,
),
{
headers: {
Authorization: `Bearer ${token.accessToken}`,
},
},
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(
"application/json",
);
const statuses = (await response.json()) as APIStatus[];
expect(statuses.length).toBe(0);
});
});
describe("POST /api/v1/accounts/:id/remove_from_followers", () => {
test("should remove the specified user from the authenticated user's followers and return an APIRelationship object", async () => {
const response = await sendTestRequest(

View file

@ -1,3 +1,6 @@
/**
* @deprecated
*/
import { afterAll, describe, expect, test } from "bun:test";
import { config } from "config-manager";
import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "~tests/utils";

View file

@ -1,13 +1,11 @@
/**
* @deprecated
*/
import { afterAll, describe, expect, test } from "bun:test";
import { config } from "~packages/config-manager";
import type { Application as APIApplication } from "~types/mastodon/application";
import type { Token as APIToken } from "~types/mastodon/token";
import {
deleteOldTestUsers,
getTestUsers,
sendTestRequest,
wrapRelativeUrl,
} from "./utils";
import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "./utils";
const base_url = config.http.base_url;
@ -70,7 +68,7 @@ describe("POST /api/auth/login/", () => {
test("should get a JWT", async () => {
const formData = new FormData();
formData.append("email", users[0]?.getUser().email ?? "");
formData.append("identifier", users[0]?.getUser().email ?? "");
formData.append("password", passwords[0]);
const response = await sendTestRequest(
@ -87,21 +85,6 @@ describe("POST /api/auth/login/", () => {
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
expect(locationHeader.pathname).toBe("/oauth/consent");
expect(locationHeader.searchParams.get("client_id")).toBe(client_id);
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
"https://example.com",
);
expect(locationHeader.searchParams.get("response_type")).toBe("code");
expect(locationHeader.searchParams.get("scope")).toBe("read write");
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
jwt =
response.headers.get("Set-Cookie")?.match(/jwt=([^;]+);/)?.[1] ??