/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>Showing 7 changed files with 95 additions and 24 deletions.
| @@ -23,7 +23,7 @@ import java.util.concurrent.TimeUnit | ||
| 23 | 23 | import kotlin.coroutines.resumeWithException |
| 24 | 24 | |
| 25 | 25 | /** 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) { | |
| 27 | 27 | val isUnauthorized: Boolean get() = status == 401 |
| 28 | 28 | } |
| 29 | 29 | |
| @@ -73,8 +73,11 @@ class Api(val http: OkHttpClient, private val server: HttpUrl, private val token | ||
| 73 | 73 | response.use { r -> |
| 74 | 74 | val body = r.body.string() |
| 75 | 75 | 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) | |
| 78 | 81 | } |
| 79 | 82 | body |
| 80 | 83 | } |
| @@ -89,8 +92,17 @@ class Api(val http: OkHttpClient, private val server: HttpUrl, private val token | ||
| 89 | 92 | /** The address of a path on the server, for images and links. */ |
| 90 | 93 | fun apiUrl(path: String, query: Map<String, String?> = emptyMap()): String = url(path, query).toString() |
| 91 | 94 | |
| 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 | + ) | |
| 94 | 106 | |
| 95 | 107 | suspend fun signUp(username: String, email: String, password: String, name: String): SessionResponse = |
| 96 | 108 | send( |
| @@ -61,6 +61,7 @@ import androidx.compose.ui.text.style.TextAlign | ||
| 61 | 61 | import androidx.compose.ui.text.style.TextOverflow |
| 62 | 62 | import androidx.compose.ui.unit.dp |
| 63 | 63 | import cc.vectrum.app.data.Api |
| 64 | + import cc.vectrum.app.data.ApiException | |
| 64 | 65 | import cc.vectrum.app.data.HOSTED_SERVER |
| 65 | 66 | import cc.vectrum.app.data.Probe |
| 66 | 67 | import cc.vectrum.app.data.Session |
| @@ -436,6 +437,9 @@ private fun AccountStep(server: String, signups: Boolean, returning: Boolean, ht | ||
| 436 | 437 | var email by rememberSaveable { mutableStateOf("") } |
| 437 | 438 | var name by rememberSaveable { mutableStateOf("") } |
| 438 | 439 | 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("") } | |
| 439 | 443 | var busy by rememberSaveable { mutableStateOf(false) } |
| 440 | 444 | var error by rememberSaveable { mutableStateOf<String?>(null) } |
| 441 | 445 | val url = normalizeServer(server) |
| @@ -449,14 +453,30 @@ private fun AccountStep(server: String, signups: Boolean, returning: Boolean, ht | ||
| 449 | 453 | error = if (signingUp) "Fill in a username, your email and a password." else "Enter your username or email and your password." |
| 450 | 454 | return |
| 451 | 455 | } |
| 456 | + if (!signingUp && needsCode && code.isBlank()) { | |
| 457 | + error = "Enter the code from your authenticator app." | |
| 458 | + return | |
| 459 | + } | |
| 452 | 460 | focus.clearFocus() |
| 453 | 461 | busy = true |
| 454 | 462 | error = null |
| 455 | 463 | scope.launch { |
| 456 | 464 | try { |
| 457 | 465 | 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 | + } | |
| 459 | 471 | 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 | + } | |
| 460 | 480 | } catch (e: Exception) { |
| 461 | 481 | error = describe(e) |
| 462 | 482 | } finally { |
| @@ -548,7 +568,7 @@ private fun AccountStep(server: String, signups: Boolean, returning: Boolean, ht | ||
| 548 | 568 | } else { |
| 549 | 569 | Field( |
| 550 | 570 | login, |
| 551 | - { login = it; error = null }, | |
| 571 | + { login = it; error = null; needsCode = false; code = "" }, | |
| 552 | 572 | label = "Username or email", |
| 553 | 573 | placeholder = "alice", |
| 554 | 574 | leading = VIcons.Person, |
| @@ -563,9 +583,21 @@ private fun AccountStep(server: String, signups: Boolean, returning: Boolean, ht | ||
| 563 | 583 | leading = VIcons.Lock, |
| 564 | 584 | password = true, |
| 565 | 585 | 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), | |
| 567 | 587 | actions = KeyboardActions(onGo = { submit() }), |
| 568 | 588 | ) |
| 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 | + } | |
| 569 | 601 | AnimatedVisibility(error != null) { ErrorLine(error.orEmpty()) } |
| 570 | 602 | } |
| 571 | 603 | } |
| @@ -3,7 +3,8 @@ import { ApiError, apiViewer, errorResponse, json } from "@/lib/app-api"; | ||
| 3 | 3 | |
| 4 | 4 | // The mobile app's JSON API (lib/app-api.ts). Paths: |
| 5 | 5 | // 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 } | |
| 7 | 8 | // POST /api/v1/signup { username, email, password, name } → { token, user } |
| 8 | 9 | // GET /api/v1/user the signed-in person |
| 9 | 10 | // GET /api/v1/home, /explore?q=, /users/<name> |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | 2 | import { person, type Actor } from "./actor"; |
| 3 | - import { userForSession } from "./auth"; | |
| 3 | + import { checkSecondFactor, userForSession } from "./auth"; | |
| 4 | 4 | import { describeEvent } from "./event-text"; |
| 5 | 5 | import { listEvents as listRepoEvents, type RepoEvent } from "./events"; |
| 6 | 6 | 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 | ||
| 51 | 51 | import { serverOrigin } from "./request-origin"; |
| 52 | 52 | import { commitStatuses, getRun, listJobs, listRuns, listSteps, readJobLogs, type Run } from "./runs"; |
| 53 | 53 | import { SIGNUPS_ENABLED } from "./signups"; |
| 54 | + import { hasTwoFactor } from "./two-factor"; | |
| 54 | 55 | import { authenticateUser, createUser, getUserByUsername, UserError, type User } from "./users"; |
| 55 | 56 | |
| 56 | 57 | /* |
| @@ -64,6 +65,8 @@ export class ApiError extends Error { | ||
| 64 | 65 | constructor( |
| 65 | 66 | readonly status: number, |
| 66 | 67 | message: string, |
| 68 | + /** More fields for the JSON answer, next to `error`. */ | |
| 69 | + readonly extra: Record<string, unknown> = {}, | |
| 67 | 70 | ) { |
| 68 | 71 | super(message); |
| 69 | 72 | } |
| @@ -74,7 +77,7 @@ const isUserError = (e: unknown) => | ||
| 74 | 77 | |
| 75 | 78 | /** Turns what a handler threw into a JSON answer: user errors become 4xx, anything else is rethrown. */ |
| 76 | 79 | 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); | |
| 78 | 81 | if (isUserError(e)) return json({ error: (e as Error).message }, 422); |
| 79 | 82 | throw e; |
| 80 | 83 | } |
| @@ -154,10 +157,21 @@ export async function signUp(request: Request) { | ||
| 154 | 157 | } |
| 155 | 158 | } |
| 156 | 159 | |
| 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 | + */ | |
| 157 | 164 | export async function signIn(request: Request) { |
| 158 | 165 | const body = await readJson(request); |
| 159 | 166 | const auth = await authenticateUser(text(body.login, 200), text(body.password, 200)); |
| 160 | 167 | 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 | + } | |
| 161 | 175 | return { token: auth.token, user: userJson(auth.user) }; |
| 162 | 176 | } |
| 163 | 177 | |
| @@ -95,19 +95,32 @@ export async function hasSignInChallenge() { | ||
| 95 | 95 | return !!(await currentChallenge()); |
| 96 | 96 | } |
| 97 | 97 | |
| 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 | + | |
| 98 | 116 | /** Finishes signing in with a code from the authenticator app or a recovery code. */ |
| 99 | 117 | export async function completeSignIn(code: string): Promise<"ok" | "invalid" | "expired" | "locked"> { |
| 100 | 118 | const current = await currentChallenge(); |
| 101 | 119 | if (!current) return "expired"; |
| 102 | 120 | 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") { | |
| 111 | 124 | if (++challenge.tries >= CHALLENGE_TRIES) { |
| 112 | 125 | challenges.delete(key); |
| 113 | 126 | await endChallenge(); |
| @@ -116,7 +129,6 @@ export async function completeSignIn(code: string): Promise<"ok" | "invalid" | " | ||
| 116 | 129 | return "invalid"; |
| 117 | 130 | } |
| 118 | 131 | challenges.delete(key); |
| 119 | - failures.delete(challenge.userId); | |
| 120 | 132 | await endChallenge(); |
| 121 | 133 | await setSessionCookie(challenge.token); |
| 122 | 134 | return "ok"; |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # Features |
| 2 | 2 | |
| 3 | 3 | - **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 | |
| 5 | 5 | - **Repositories**: public or private, rename, delete, optional README on creation |
| 6 | 6 | - **Collaborators**: give other users push access to a repository |
| 7 | 7 | - **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 |
| @@ -43,8 +43,8 @@ The first time it opens, the app walks through three steps: | ||
| 43 | 43 | 2. **Server**: *Vectrum Cloud* (vectrum.cc) or *Self-hosted*, with the server's address |
| 44 | 44 | (`git.example.com`, `192.168.1.10:3000`). Plain HTTP is fine; without `http://` or `https://`, HTTPS is |
| 45 | 45 | 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`). | |
| 48 | 48 | |
| 49 | 49 | After signing out, the app opens on the account step for the same server, with *Change* to pick another. |
| 50 | 50 | 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 | ||
| 65 | 65 | | Request | | |
| 66 | 66 | | --- | --- | |
| 67 | 67 | | `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 | | |
| 69 | 69 | | `POST /api/v1/signup` | `{ username, email, password, name }` → `{ token, user }`, when sign-ups are open | |
| 70 | 70 | | `GET /api/v1/user`, `/home`, `/explore?q=`, `/users/<name>` | | |
| 71 | 71 | | `GET /api/v1/repos/<owner>/<repo>` | the repository, its README, root files, branches and counts | |