mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
feat(api): 🎨 Allow login with either username or email
This commit is contained in:
parent
47c88dd7dd
commit
f9c9a7d527
230
server/api/api/auth/login/index.test.ts
Normal file
230
server/api/api/auth/login/index.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { applyConfig, handleZodError } from "@api";
|
import { applyConfig, handleZodError } from "@api";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { errorResponse, response } from "@response";
|
import { errorResponse, response } from "@response";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, or } from "drizzle-orm";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -24,7 +24,11 @@ export const meta = applyConfig({
|
||||||
|
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
form: z.object({
|
form: z.object({
|
||||||
email: z.string().email().toLowerCase(),
|
identifier: z
|
||||||
|
.string()
|
||||||
|
.email()
|
||||||
|
.toLowerCase()
|
||||||
|
.or(z.string().toLowerCase()),
|
||||||
password: z.string().min(2).max(100),
|
password: z.string().min(2).max(100),
|
||||||
}),
|
}),
|
||||||
query: z.object({
|
query: z.object({
|
||||||
|
|
@ -69,7 +73,10 @@ const returnError = (query: object, error: string, description: string) => {
|
||||||
searchParams.append("error_description", description);
|
searchParams.append("error_description", description);
|
||||||
|
|
||||||
return response(null, 302, {
|
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("form", schemas.form, handleZodError),
|
||||||
zValidator("query", schemas.query, handleZodError),
|
zValidator("query", schemas.query, handleZodError),
|
||||||
async (context) => {
|
async (context) => {
|
||||||
const { email, password } = context.req.valid("form");
|
const { identifier, password } = context.req.valid("form");
|
||||||
const { client_id } = context.req.valid("query");
|
const { client_id } = context.req.valid("query");
|
||||||
|
|
||||||
// Find user
|
// Find user
|
||||||
const user = await User.fromSql(
|
const user = await User.fromSql(
|
||||||
eq(Users.email, email.toLowerCase()),
|
or(
|
||||||
|
eq(Users.email, identifier.toLowerCase()),
|
||||||
|
eq(Users.username, identifier.toLowerCase()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|
@ -97,7 +107,7 @@ export default (app: Hono) =>
|
||||||
)
|
)
|
||||||
return returnError(
|
return returnError(
|
||||||
context.req.query(),
|
context.req.query(),
|
||||||
"invalid_request",
|
"invalid_grant",
|
||||||
"Invalid email or password",
|
"Invalid email or password",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,6 @@ afterAll(async () => {
|
||||||
await deleteUsers();
|
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 () => {
|
beforeAll(async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
new Request(
|
new Request(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "~tests/utils";
|
import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "~tests/utils";
|
||||||
import type { Account as APIAccount } from "~types/mastodon/account";
|
import type { Account as APIAccount } from "~types/mastodon/account";
|
||||||
import type { Relationship as APIRelationship } from "~types/mastodon/relationship";
|
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;
|
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", () => {
|
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 () => {
|
test("should remove the specified user from the authenticated user's followers and return an APIRelationship object", async () => {
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { config } from "config-manager";
|
import { config } from "config-manager";
|
||||||
import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "~tests/utils";
|
import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "~tests/utils";
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import { config } from "~packages/config-manager";
|
import { config } from "~packages/config-manager";
|
||||||
import type { Application as APIApplication } from "~types/mastodon/application";
|
import type { Application as APIApplication } from "~types/mastodon/application";
|
||||||
import type { Token as APIToken } from "~types/mastodon/token";
|
import type { Token as APIToken } from "~types/mastodon/token";
|
||||||
import {
|
import { getTestUsers, sendTestRequest, wrapRelativeUrl } from "./utils";
|
||||||
deleteOldTestUsers,
|
|
||||||
getTestUsers,
|
|
||||||
sendTestRequest,
|
|
||||||
wrapRelativeUrl,
|
|
||||||
} from "./utils";
|
|
||||||
|
|
||||||
const base_url = config.http.base_url;
|
const base_url = config.http.base_url;
|
||||||
|
|
||||||
|
|
@ -70,7 +68,7 @@ describe("POST /api/auth/login/", () => {
|
||||||
test("should get a JWT", async () => {
|
test("should get a JWT", async () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
formData.append("email", users[0]?.getUser().email ?? "");
|
formData.append("identifier", users[0]?.getUser().email ?? "");
|
||||||
formData.append("password", passwords[0]);
|
formData.append("password", passwords[0]);
|
||||||
|
|
||||||
const response = await sendTestRequest(
|
const response = await sendTestRequest(
|
||||||
|
|
@ -87,21 +85,6 @@ describe("POST /api/auth/login/", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
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 =
|
jwt =
|
||||||
response.headers.get("Set-Cookie")?.match(/jwt=([^;]+);/)?.[1] ??
|
response.headers.get("Set-Cookie")?.match(/jwt=([^;]+);/)?.[1] ??
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue