mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 01:35:19 +03:00
fix(auth): give a sign-in code a budget of guesses and a short life
A six-digit code has a million values, and nothing was counting how many of them a caller tried. The code travelled in an encrypted cookie the caller held, verification compared against that cookie, and a wrong answer simply re-rendered the form. Nobody has to be the person the code was mailed to: type somebody else's address into the first screen and the code goes to their mailbox while the cookie stays with you. At that point the only thing between a stranger and an account is a million requests, and the constant-time comparison protecting the code was guarding a door you could just keep knocking on. Guesses are now counted on the server, under a name that changes with every code. That placement is the point: a counter kept beside the code, in the cookie, is a counter the guesser can wind back by replaying an older copy. Starting over is still allowed and still costs a fresh code sent to the mailbox being aimed at, which is where somebody notices. A correct code spends its record too, so its remaining guesses do not carry into the next one. The cookie also lived for twenty-four hours, which made the pin a password with a million possible values and a day to try them. Ten minutes now, and the code stops being accepted when the clock says so rather than when the cookie happens to go away. Resend had no limit either, so the button was a way to mail a stranger as fast as requests go out. Codes to one address are spaced, and one attempt at signing in can only ask for so many. Both refusals say the same thing on purpose. Which of the two it was is a fact about somebody else's mailbox.
This commit is contained in:
@@ -95,8 +95,14 @@ function jar() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Ask for a code, redeem it, and come back holding tokens. */
|
||||
async function signIn() {
|
||||
/**
|
||||
* Ask for a code, redeem it, and come back holding tokens.
|
||||
*
|
||||
* The address is a parameter because codes to one mailbox are rate limited, and
|
||||
* two sign-ins in the same second are exactly what that limit is for. Each
|
||||
* caller uses its own.
|
||||
*/
|
||||
async function signIn(email: string) {
|
||||
const client = createClient({
|
||||
issuer: 'https://auth.internal',
|
||||
clientID: 'api',
|
||||
@@ -115,7 +121,7 @@ async function signIn() {
|
||||
const requested = await auth.request('https://auth.internal/code/authorize', {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookies.header(), 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ action: 'request', email: 'ada@example.com' })
|
||||
body: new URLSearchParams({ action: 'request', email })
|
||||
});
|
||||
cookies.absorb(requested);
|
||||
expect(lastCode).not.toBe('');
|
||||
@@ -142,7 +148,7 @@ async function signIn() {
|
||||
|
||||
describe('signing in with an email address', () => {
|
||||
test('a redeemed code becomes tokens that verify', async () => {
|
||||
const { client, tokens } = await signIn();
|
||||
const { client, tokens } = await signIn('ada@example.com');
|
||||
|
||||
expect(tokens.access).toBeString();
|
||||
expect(tokens.refresh).toBeString();
|
||||
@@ -161,7 +167,7 @@ describe('signing in with an email address', () => {
|
||||
|
||||
describe('User info', () => {
|
||||
test('returns subject properties for valid access token', async () => {
|
||||
const { tokens } = await signIn();
|
||||
const { tokens } = await signIn('grace@example.com');
|
||||
|
||||
const infoRes = await auth.request('https://auth.internal/userinfo', {
|
||||
headers: { Authorization: `Bearer ${tokens.access}` }
|
||||
|
||||
Reference in New Issue
Block a user