Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ No One(无名):Next Generation Polyglot Website Manager,打破语言边
- 不仅支持 Java 站点 同时支持 Node.js + ASP.NET 站点
- 支持完善的审计、登录以及站点操作日志
- 使用 RBAC 模型,不仅支持单兵作战也支持协同作战
- 默认开启 2FA + 双 Token 刷新机制,且支持吊销 Token
- 支持 2FA(默认关闭)+ 双 Token 刷新机制,且支持吊销 Token
- 前端使用服务端渲染,后端无需开放端口也无需使用 Nginx 反代,大幅降低攻击面
- 插件系统支持上传异步执行插件或定时任务插件,向站点发送插件包时对应的进行异步或定时任务执行
- ......
Expand Down
2 changes: 1 addition & 1 deletion noone-docs/content/docs/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ icon: Network
### 认证与授权

1. 系统不开放自注册,仅允许管理员后台创建账号。
2. 用户首次登录必须修改初始密码,并完成 2FA 绑定。
2. 用户首次登录必须修改初始密码,2FA 默认关闭。
3. 支持登录日志记录,包含登录时间、IP、设备信息、登录结果等。
4. 使用 `access_token + refresh_token` 的 JWT 会话机制,支持多端登录、会话管理与主动下线。
5. 管理员可执行密码重置并下发临时密码;用户再次登录后必须修改密码。
Expand Down
2 changes: 1 addition & 1 deletion noone-docs/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ No One(无名):Next Generation Polyglot Website Manager,打破语言边
- 不仅支持 Java 站点 同时支持 Node.js + ASP.NET 站点
- 支持完善的审计、登录以及站点操作日志
- 使用 RBAC 模型,不仅支持单兵作战也支持协同作战
- 默认开启 2FA + 双 Token 刷新机制,且支持吊销 Token
- 支持 2FA(默认关闭)+ 双 Token 刷新机制,且支持吊销 Token
- 前端使用服务端渲染,后端无需开放端口也无需使用 Nginx 反代,大幅降低攻击面
- 插件系统支持上传异步执行插件或定时任务插件,向站点发送插件包时对应的进行异步或定时任务执行
- ......
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,17 @@ public ResponseEntity<?> activateUser(
}

User user = optionalUser.get();
if (!twoFactorAuthService.isCodeValid(user.getMfaSecret(), request.getTwoFactorCode())) {
String twoFactorCode = request.getTwoFactorCode();
boolean enableTwoFactor = twoFactorCode != null && !twoFactorCode.isBlank();
if (enableTwoFactor && !twoFactorAuthService.isCodeValid(user.getMfaSecret(), twoFactorCode)) {
return ResponseEntity.badRequest().body("Invalid 2FA code");
}

UserResponse updated = userService.forceChangePassword(username, request.getNewPassword());
User latestUser = userService.getByUsername(updated.getUsername());
latestUser.setStatus(UserStatus.ENABLED);
latestUser.setMfaEnabled(true);
latestUser.setMfaBoundAt(LocalDateTime.now());
latestUser.setMfaEnabled(enableTwoFactor);
latestUser.setMfaBoundAt(enableTwoFactor ? LocalDateTime.now() : null);
latestUser.setMustChangePassword(false);
userRepository.save(latestUser);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

@Data
public class SetupCodeRequest {
@NotBlank(message = "Two factor code is required")
private String twoFactorCode;

@NotBlank(message = "New password is required")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
import java.util.function.Supplier;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
Expand Down Expand Up @@ -186,6 +186,66 @@ void verify2faShouldRejectWrongTokenType() throws Exception {
.andExpect(jsonPath("$.code").value("INVALID_2FA_CHALLENGE"));
}

@Test
void activateShouldKeepTwoFactorDisabledWhenCodeIsNotProvided() throws Exception {
User user = userRepository.save(User.builder()
.username("setup-user")
.password(passwordEncoder.encode("initial-password"))
.email("setup-user@example.com")
.status(UserStatus.UNACTIVATED)
.mfaSecret("setup-mfa-secret")
.mustChangePassword(true)
.deleted(false)
.build());
String setupToken = jwtUtil.generateSetupToken(user.getUsername());

mockMvc.perform(post("/api/setup/activate")
.header("Setup-Token", setupToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of(
"newPassword", "new-password"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").isNotEmpty())
.andExpect(jsonPath("$.refreshToken").isNotEmpty())
.andExpect(jsonPath("$.user.mfaEnabled").value(false))
.andExpect(jsonPath("$.mfaRequired").value(false));

User activatedUser = userRepository.findByUsernameAndDeletedFalse(user.getUsername()).orElseThrow();
assertThat(activatedUser.getStatus()).isEqualTo(UserStatus.ENABLED);
assertThat(activatedUser.isMfaEnabled()).isFalse();
assertThat(activatedUser.getMfaBoundAt()).isNull();
assertThat(activatedUser.isMustChangePassword()).isFalse();
verifyNoInteractions(twoFactorAuthService);
}

@Test
void activateShouldEnableTwoFactorWhenValidCodeIsProvided() throws Exception {
User user = userRepository.save(User.builder()
.username("setup-mfa-user")
.password(passwordEncoder.encode("initial-password"))
.email("setup-mfa-user@example.com")
.status(UserStatus.UNACTIVATED)
.mfaSecret("setup-mfa-secret")
.mustChangePassword(true)
.deleted(false)
.build());
String setupToken = jwtUtil.generateSetupToken(user.getUsername());
when(twoFactorAuthService.isCodeValid("setup-mfa-secret", "123456")).thenReturn(true);

mockMvc.perform(post("/api/setup/activate")
.header("Setup-Token", setupToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of(
"newPassword", "new-password",
"twoFactorCode", "123456"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.user.mfaEnabled").value(true));

User activatedUser = userRepository.findByUsernameAndDeletedFalse(user.getUsername()).orElseThrow();
assertThat(activatedUser.isMfaEnabled()).isTrue();
assertThat(activatedUser.getMfaBoundAt()).isNotNull();
}

// @Test
// void refreshShouldRejectRefreshReuseAndRevokeSession() throws Exception {
// UserSession session = userSessionRepository.save(UserSession.builder()
Expand Down
70 changes: 6 additions & 64 deletions noone-web/app/routes/auth/setup/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ type LoginApiSuccess = {
user?: User;
};

type SetupQrResponse = {
qrCodeUri?: string;
};

function isLoginSuccessResponse(
response: LoginApiSuccess,
): response is Required<Pick<LoginApiSuccess, "token" | "refreshToken" | "user">> &
Expand All @@ -42,39 +38,22 @@ function getFormString(formData: FormData, key: string): string | null {
}

export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const token = url.searchParams.get("token");
const token = new URL(request.url).searchParams.get("token");

if (!token) {
return redirect("/auth/login");
}

try {
const response = await createPublicApi(request)<SetupQrResponse>("/setup/2fa/qr", {
method: "GET",
headers: {
"Setup-Token": token,
},
});
const qrUri = response.qrCodeUri;
if (!qrUri) {
return redirect("/auth/login");
}
return { token, qrUri };
} catch (error: any) {
console.error("Failed to fetch setup QR code:", error);
return redirect("/auth/login");
}
return { token };
}

export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const setupToken = getFormString(formData, "token") ?? "";
const newPassword = getFormString(formData, "newPassword") ?? "";
const confirmPassword = getFormString(formData, "confirmPassword") ?? "";
const twoFactorCode = getFormString(formData, "twoFactorCode") ?? "";

if (!setupToken || !newPassword || !confirmPassword || !twoFactorCode) {
if (!setupToken || !newPassword || !confirmPassword) {
return { error: "All fields are required" };
}

Expand All @@ -90,7 +69,6 @@ export async function action({ request }: ActionFunctionArgs) {
},
body: {
newPassword,
twoFactorCode,
},
});

Expand All @@ -115,13 +93,13 @@ export async function action({ request }: ActionFunctionArgs) {
error?.details?.message ??
error?.details?.error ??
error?.message ??
"Failed to activate account. Please verify your 2FA code.",
"Failed to activate account.",
};
}
}

function SetupForm({ className, ...props }: ComponentProps<"div">) {
const { token, qrUri } = useLoaderData() as { token: string; qrUri: string };
const { token } = useLoaderData() as { token: string };
const actionData = useActionData() as { error?: string } | undefined;
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
Expand All @@ -132,7 +110,7 @@ function SetupForm({ className, ...props }: ComponentProps<"div">) {
<AuthShell
eyebrow="Account Activation"
title="No One"
description="Secure your account by changing your password and setting up two-factor authentication."
description="Choose a new password to activate your account and enter the console."
>
<Form method="post" className="w-full">
<input type="hidden" name="token" value={token} />
Expand All @@ -144,23 +122,6 @@ function SetupForm({ className, ...props }: ComponentProps<"div">) {
centered
/>

<div className="flex flex-col items-center gap-4 rounded-xl border border-border bg-muted/40 p-4">
<div className="text-center text-sm font-medium text-foreground">
Scan this QR code with your authenticator app
</div>
{qrUri ? (
<img
src={qrUri}
alt="2FA QR code"
className="size-40 rounded-xl border border-border bg-background p-2 shadow-sm"
/>
) : (
<div className="flex size-40 items-center justify-center rounded-xl border border-border bg-background text-sm text-muted-foreground shadow-sm">
Unable to load QR
</div>
)}
</div>

<div className="grid gap-4">
<div className="grid gap-2.5">
<Label htmlFor="newPassword" className={authLabelClassName}>
Expand Down Expand Up @@ -194,25 +155,6 @@ function SetupForm({ className, ...props }: ComponentProps<"div">) {
/>
</div>

<div className="grid gap-2.5">
<Label htmlFor="twoFactorCode" className={authLabelClassName}>
6-digit Authenticator Code
</Label>
<Input
id="twoFactorCode"
name="twoFactorCode"
type="text"
required
maxLength={6}
placeholder="123456"
disabled={isSubmitting}
autoComplete="one-time-code"
aria-invalid={actionData?.error ? true : undefined}
aria-describedby={actionData?.error ? statusId : undefined}
className={cn(authInputClassName, "text-center tracking-[0.16em]")}
/>
</div>

<Button
type="submit"
className={cn(authPrimaryButtonClassName, "mt-1")}
Expand Down
66 changes: 66 additions & 0 deletions noone-web/app/routes/auth/setup/setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const { createPublicApiMock, getSessionMock, commitSessionMock } = vi.hoisted(() => ({
createPublicApiMock: vi.fn(),
getSessionMock: vi.fn(),
commitSessionMock: vi.fn(),
}));

vi.mock("@/api/api.server", () => ({
createPublicApi: createPublicApiMock,
}));

vi.mock("@/api/sessions.server", () => ({
getSession: getSessionMock,
commitSession: commitSessionMock,
}));

import { action as setupAction, loader as setupLoader } from "@/routes/auth/setup/route";

describe("auth setup flow", () => {
beforeEach(() => {
vi.clearAllMocks();
commitSessionMock.mockResolvedValue("__session=updated;");
});

it("loads the password setup form without requesting a 2FA QR code", async () => {
const request = new Request("http://localhost/auth/setup?token=setup-token");

const result = await setupLoader({ request } as any);

expect(result).toEqual({ token: "setup-token" });
expect(createPublicApiMock).not.toHaveBeenCalled();
});

it("activates with only a new password and enters the console", async () => {
const session = { set: vi.fn() };
const apiCall = vi.fn().mockResolvedValue({
token: "access-token",
refreshToken: "refresh-token",
user: { id: 1, username: "admin", mfaEnabled: false },
});
getSessionMock.mockResolvedValue(session);
createPublicApiMock.mockReturnValue(apiCall);

const request = new Request("http://localhost/auth/setup", {
method: "POST",
body: new URLSearchParams({
token: "setup-token",
newPassword: "new-password",
confirmPassword: "new-password",
}),
});

const response = (await setupAction({ request } as any)) as Response;

expect(apiCall).toHaveBeenCalledWith("/setup/activate", {
method: "POST",
headers: { "Setup-Token": "setup-token" },
body: { newPassword: "new-password" },
});
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/");
expect(session.set).toHaveBeenCalledWith("accessToken", "access-token");
expect(session.set).toHaveBeenCalledWith("refreshToken", "refresh-token");
});
});
Loading