Vectrum
Sign In

Ask for the 2FA code in the Android app's sign-in

/api/v1/session signed in with a password alone, which went around two-factor
authentication. With it on, the API now answers 401 { twoFactor: true } until a code from
the authenticator app or a recovery code comes with the password, counted against the same
per-account limit as the website. The app shows a code field when asked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent 155bdf2commitdc4a55e

Showing 7 changed files with 95 additions and 24 deletions.

+17−5apps/mobile/app/src/main/java/cc/vectrum/app/data/Api.ktView File
@@ -23,7 +23,7 @@ import java.util.concurrent.TimeUnit
2323 import kotlin.coroutines.resumeWithException
2424
2525 /** What went wrong talking to the server, in words a person can act on. */
26- class ApiException(val status: Int, message: String) : Exception(message) {
26+ class ApiException(val status: Int, message: String, val twoFactor: Boolean = false) : Exception(message) {
2727 val isUnauthorized: Boolean get() = status == 401
2828 }
2929
@@ -73,8 +73,11 @@ class Api(val http: OkHttpClient, private val server: HttpUrl, private val token
7373 response.use { r ->
7474 val body = r.body.string()
7575 if (!r.isSuccessful) {
76- val message = runCatching { json.parseToJsonElement(body).jsonObject["error"]?.jsonPrimitive?.content }.getOrNull()
77- throw ApiException(r.code, message ?: "The server answered ${r.code}.")
76+ val fields = runCatching { json.parseToJsonElement(body).jsonObject }.getOrNull()
77+ val message = fields?.get("error")?.let { runCatching { it.jsonPrimitive.content }.getOrNull() }
78+ // A sign-in to an account with two-factor authentication on, that needs a code.
79+ val twoFactor = fields?.get("twoFactor")?.let { runCatching { it.jsonPrimitive.content == "true" }.getOrNull() } ?: false
80+ throw ApiException(r.code, message ?: "The server answered ${r.code}.", twoFactor)
7881 }
7982 body
8083 }
@@ -89,8 +92,17 @@ class Api(val http: OkHttpClient, private val server: HttpUrl, private val token
8992 /** The address of a path on the server, for images and links. */
9093 fun apiUrl(path: String, query: Map<String, String?> = emptyMap()): String = url(path, query).toString()
9194
92- suspend fun signIn(login: String, password: String): SessionResponse =
93- send("POST", "session", buildJsonObject { put("login", JsonPrimitive(login)); put("password", JsonPrimitive(password)) })
95+ /** [code] is from the authenticator app (or a recovery code), for accounts with two-factor authentication on. */
96+ suspend fun signIn(login: String, password: String, code: String? = null): SessionResponse =
97+ send(
98+ "POST",
99+ "session",
100+ buildJsonObject {
101+ put("login", JsonPrimitive(login))
102+ put("password", JsonPrimitive(password))
103+ if (code != null) put("code", JsonPrimitive(code))
104+ },
105+ )
94106
95107 suspend fun signUp(username: String, email: String, password: String, name: String): SessionResponse =
96108 send(
+35−3apps/mobile/app/src/main/java/cc/vectrum/app/ui/screens/SetupScreen.ktView File
@@ -61,6 +61,7 @@ import androidx.compose.ui.text.style.TextAlign
6161 import androidx.compose.ui.text.style.TextOverflow
6262 import androidx.compose.ui.unit.dp
6363 import cc.vectrum.app.data.Api
64+ import cc.vectrum.app.data.ApiException
6465 import cc.vectrum.app.data.HOSTED_SERVER
6566 import cc.vectrum.app.data.Probe
6667 import cc.vectrum.app.data.Session
@@ -436,6 +437,9 @@ private fun AccountStep(server: String, signups: Boolean, returning: Boolean, ht
436437 var email by rememberSaveable { mutableStateOf("") }
437438 var name by rememberSaveable { mutableStateOf("") }
438439 var password by rememberSaveable { mutableStateOf("") }
440+ // Set once the server says the account has two-factor authentication on.
441+ var needsCode by rememberSaveable { mutableStateOf(false) }
442+ var code by rememberSaveable { mutableStateOf("") }
439443 var busy by rememberSaveable { mutableStateOf(false) }
440444 var error by rememberSaveable { mutableStateOf<String?>(null) }
441445 val url = normalizeServer(server)
@@ -449,14 +453,30 @@ private fun AccountStep(server: String, signups: Boolean, returning: Boolean, ht
449453 error = if (signingUp) "Fill in a username, your email and a password." else "Enter your username or email and your password."
450454 return
451455 }
456+ if (!signingUp && needsCode && code.isBlank()) {
457+ error = "Enter the code from your authenticator app."
458+ return
459+ }
452460 focus.clearFocus()
453461 busy = true
454462 error = null
455463 scope.launch {
456464 try {
457465 val api = Api(http, target, null)
458- val result = if (signingUp) api.signUp(username.trim(), email.trim(), password, name.trim()) else api.signIn(login.trim(), password)
466+ val result = if (signingUp) {
467+ api.signUp(username.trim(), email.trim(), password, name.trim())
468+ } else {
469+ api.signIn(login.trim(), password, code.trim().takeIf { needsCode && it.isNotEmpty() })
470+ }
459471 onSignedIn(Session(target.toString(), result.token, result.user))
472+ } catch (e: ApiException) {
473+ if (e.twoFactor && !signingUp) {
474+ // The password was right; the first time, just ask for the code.
475+ error = if (needsCode) e.message else null
476+ needsCode = true
477+ } else {
478+ error = describe(e)
479+ }
460480 } catch (e: Exception) {
461481 error = describe(e)
462482 } finally {
@@ -548,7 +568,7 @@ private fun AccountStep(server: String, signups: Boolean, returning: Boolean, ht
548568 } else {
549569 Field(
550570 login,
551- { login = it; error = null },
571+ { login = it; error = null; needsCode = false; code = "" },
552572 label = "Username or email",
553573 placeholder = "alice",
554574 leading = VIcons.Person,
@@ -563,9 +583,21 @@ private fun AccountStep(server: String, signups: Boolean, returning: Boolean, ht
563583 leading = VIcons.Lock,
564584 password = true,
565585 hint = if (signingUp) "At least 8 characters." else null,
566- keyboard = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
586+ keyboard = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = if (needsCode && !signingUp) ImeAction.Next else ImeAction.Go),
567587 actions = KeyboardActions(onGo = { submit() }),
568588 )
589+ AnimatedVisibility(needsCode && !signingUp) {
590+ Field(
591+ code,
592+ { code = it; error = null },
593+ label = "Authentication code",
594+ placeholder = "123456",
595+ leading = VIcons.Key,
596+ hint = "From your authenticator app. Lost your device? Enter one of your recovery codes.",
597+ keyboard = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Go, autoCorrectEnabled = false),
598+ actions = KeyboardActions(onGo = { submit() }),
599+ )
600+ }
569601 AnimatedVisibility(error != null) { ErrorLine(error.orEmpty()) }
570602 }
571603 }
+2−1apps/web/app/api/v1/[...path]/route.tsView File
@@ -3,7 +3,8 @@ import { ApiError, apiViewer, errorResponse, json } from "@/lib/app-api";
33
44 // The mobile app's JSON API (lib/app-api.ts). Paths:
55 // GET /api/v1/server { name, api, signups }: what the app's setup checks first
6- // POST /api/v1/session sign in: { login, password } → { token, user }
6+ // POST /api/v1/session sign in: { login, password, code? } → { token, user }; code is for
7+ // two-factor authentication, asked for with 401 { twoFactor: true }
78 // POST /api/v1/signup { username, email, password, name } → { token, user }
89 // GET /api/v1/user the signed-in person
910 // GET /api/v1/home, /explore?q=, /users/<name>
+16−2apps/web/lib/app-api.tsView File
@@ -1,6 +1,6 @@
11 import "server-only";
22 import { person, type Actor } from "./actor";
3- import { userForSession } from "./auth";
3+ import { checkSecondFactor, userForSession } from "./auth";
44 import { describeEvent } from "./event-text";
55 import { listEvents as listRepoEvents, type RepoEvent } from "./events";
66 import { commitCount, defaultBranch, diffCommit, getCommit, GitError, listBranches, listTags, listTree, log, normalizeRepoName, objectType, readBlob, revParse, type Commit, type FileDiff } from "./git";
@@ -51,6 +51,7 @@ import { canWrite, findRepo, getRepoImage, listReposByOwner, listReposForUser, r
5151 import { serverOrigin } from "./request-origin";
5252 import { commitStatuses, getRun, listJobs, listRuns, listSteps, readJobLogs, type Run } from "./runs";
5353 import { SIGNUPS_ENABLED } from "./signups";
54+ import { hasTwoFactor } from "./two-factor";
5455 import { authenticateUser, createUser, getUserByUsername, UserError, type User } from "./users";
5556
5657 /*
@@ -64,6 +65,8 @@ export class ApiError extends Error {
6465 constructor(
6566 readonly status: number,
6667 message: string,
68+ /** More fields for the JSON answer, next to `error`. */
69+ readonly extra: Record<string, unknown> = {},
6770 ) {
6871 super(message);
6972 }
@@ -74,7 +77,7 @@ const isUserError = (e: unknown) =>
7477
7578 /** Turns what a handler threw into a JSON answer: user errors become 4xx, anything else is rethrown. */
7679 export function errorResponse(e: unknown) {
77- if (e instanceof ApiError) return json({ error: e.message }, e.status);
80+ if (e instanceof ApiError) return json({ ...e.extra, error: e.message }, e.status);
7881 if (isUserError(e)) return json({ error: (e as Error).message }, 422);
7982 throw e;
8083 }
@@ -154,10 +157,21 @@ export async function signUp(request: Request) {
154157 }
155158 }
156159
160+ /**
161+ * With two-factor authentication on, the app sends the password again with `code`, a code from the
162+ * authenticator app or a recovery code. Without one it's told so: a 401 with `twoFactor: true`.
163+ */
157164 export async function signIn(request: Request) {
158165 const body = await readJson(request);
159166 const auth = await authenticateUser(text(body.login, 200), text(body.password, 200));
160167 if (!auth) throw new ApiError(401, "Incorrect username or password.");
168+ if (await hasTwoFactor(auth.user.id)) {
169+ const code = text(body.code, 40);
170+ if (!code) throw new ApiError(401, "Enter the code from your authenticator app.", { twoFactor: true });
171+ const result = await checkSecondFactor(auth.user.id, code);
172+ if (result === "locked") throw new ApiError(429, "Too many wrong codes. Wait 15 minutes, then try again.", { twoFactor: true });
173+ if (result === "invalid") throw new ApiError(401, "That code didn't match. Try the newest code from your app.", { twoFactor: true });
174+ }
161175 return { token: auth.token, user: userJson(auth.user) };
162176 }
163177
+21−9apps/web/lib/auth.tsView File
@@ -95,19 +95,32 @@ export async function hasSignInChallenge() {
9595 return !!(await currentChallenge());
9696 }
9797
98+ /**
99+ * Checks a code from the authenticator app or a recovery code, counting wrong ones per user. The
100+ * mobile app's sign-in (lib/app-api.ts) uses this too.
101+ */
102+ export async function checkSecondFactor(userId: string, code: string): Promise<"ok" | "invalid" | "locked"> {
103+ const now = Date.now();
104+ const failed = failures.get(userId);
105+ if (failed && now - failed.since > FAILURE_WINDOW_MS) failures.delete(userId);
106+ if ((failures.get(userId)?.count ?? 0) >= FAILURE_LIMIT) return "locked";
107+ if (!(await verifySecondFactor(userId, code))) {
108+ const f = failures.get(userId) ?? { count: 0, since: now };
109+ failures.set(userId, { ...f, count: f.count + 1 });
110+ return "invalid";
111+ }
112+ failures.delete(userId);
113+ return "ok";
114+ }
115+
98116 /** Finishes signing in with a code from the authenticator app or a recovery code. */
99117 export async function completeSignIn(code: string): Promise<"ok" | "invalid" | "expired" | "locked"> {
100118 const current = await currentChallenge();
101119 if (!current) return "expired";
102120 const { key, challenge } = current;
103- const now = Date.now();
104- const failed = failures.get(challenge.userId);
105- if (failed && now - failed.since > FAILURE_WINDOW_MS) failures.delete(challenge.userId);
106- if ((failures.get(challenge.userId)?.count ?? 0) >= FAILURE_LIMIT) return "locked";
107-
108- if (!(await verifySecondFactor(challenge.userId, code))) {
109- const f = failures.get(challenge.userId) ?? { count: 0, since: now };
110- failures.set(challenge.userId, { ...f, count: f.count + 1 });
121+ const result = await checkSecondFactor(challenge.userId, code);
122+ if (result === "locked") return "locked";
123+ if (result === "invalid") {
111124 if (++challenge.tries >= CHALLENGE_TRIES) {
112125 challenges.delete(key);
113126 await endChallenge();
@@ -116,7 +129,6 @@ export async function completeSignIn(code: string): Promise<"ok" | "invalid" | "
116129 return "invalid";
117130 }
118131 challenges.delete(key);
119- failures.delete(challenge.userId);
120132 await endChallenge();
121133 await setSessionCookie(challenge.token);
122134 return "ok";
+1−1docs/features.mdView File
@@ -1,7 +1,7 @@
11 # Features
22
33 - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions)
4- - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password; SSH keys, API tokens and connected apps work as before
4+ - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password, on the website and in the Android app; SSH keys, API tokens and connected apps work as before
55 - **Repositories**: public or private, rename, delete, optional README on creation
66 - **Collaborators**: give other users push access to a repository
77 - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private
+3−3docs/mobile.mdView File
@@ -43,8 +43,8 @@ The first time it opens, the app walks through three steps:
4343 2. **Server**: *Vectrum Cloud* (vectrum.cc) or *Self-hosted*, with the server's address
4444 (`git.example.com`, `192.168.1.10:3000`). Plain HTTP is fine; without `http://` or `https://`, HTTPS is
4545 tried first, then HTTP. The app checks that the server is a Vectrum new enough for the app before going on.
46- 3. **Account**: sign in with a username or email and password, or create an account when the server takes
47- sign-ups (not with `SIGNUPS_DISABLED`).
46+ 3. **Account**: sign in with a username or email and password (and a code from your authenticator app if
47+ two-factor authentication is on), or create an account when the server takes sign-ups (not with `SIGNUPS_DISABLED`).
4848
4949 After signing out, the app opens on the account step for the same server, with *Change* to pick another.
5050 From the Android emulator, the computer running `bun dev` is `http://10.0.2.2:3000`.
@@ -65,7 +65,7 @@ approving, requesting changes and merging need push access and the base branch's
6565 | Request | |
6666 | --- | --- |
6767 | `GET /api/v1/server` | `{ name, api, signups }`: what the setup checks before signing in |
68- | `POST /api/v1/session` | `{ login, password }` → `{ token, user }` |
68+ | `POST /api/v1/session` | `{ login, password, code? }` → `{ token, user }`. With two-factor authentication on, it answers 401 `{ twoFactor: true }` until `code` (from the authenticator app, or a recovery code) is sent too |
6969 | `POST /api/v1/signup` | `{ username, email, password, name }` → `{ token, user }`, when sign-ups are open |
7070 | `GET /api/v1/user`, `/home`, `/explore?q=`, `/users/<name>` | |
7171 | `GET /api/v1/repos/<owner>/<repo>` | the repository, its README, root files, branches and counts |