feat: an account may have no password, and may set one later

Single sign-on wrote a random string nobody would ever know. That reads as
"has a password" to everything that asks — so Settings demanded a current
password before it would let those accounts set their first, and the only way
through was to click "forgot password" for a password they never had. The same
trap was waiting for anybody who only ever signs in with a code.

Null says the true thing. Signing in refuses an account with no password the
way it refuses a wrong one, because which accounts have one is not a question
that endpoint answers. Setting a first password asks for no current one;
changing an existing password still does. `/auth/me` reports whether there is
one at all and nothing about it, because Settings has to choose between "Set a
password" and "Change password" and cannot tell from the outside.

The random strings already written are left alone. They are unguessable, so
nothing can sign in with them, and clearing them would mean deciding from
outside which accounts were meant to have one.

Identity is the email address throughout, so the three ways in are three ways
into the same account: single sign-on, a code, or a password — and a person may
acquire or drop the third at any point without losing the other two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 17:39:29 +02:00
parent 25a9a8aca4
commit dd7bd3668e
5 changed files with 91 additions and 16 deletions

View file

@ -0,0 +1,30 @@
"""An account may have no password.
Single sign-on wrote a random string nobody would ever know, which reads as
"has a password" to everything that asks so Settings demanded a current
password before it would let those accounts set their first, and the only way
through was to click "forgot password" for one they never had.
Null says the true thing. The random strings already written are left alone:
they are unguessable, so nothing can sign in with them, and clearing them would
mean deciding from outside which accounts were meant to have one.
Revision ID: h1b2c3d4e5f6
Revises: g9a1b2c3d4e5
"""
import sqlalchemy as sa
from alembic import op
revision = "h1b2c3d4e5f6"
down_revision = "g9a1b2c3d4e5"
branch_labels = None
depends_on = None
def upgrade():
op.alter_column("users", "hashed_password", existing_type=sa.String(), nullable=True)
def downgrade():
op.execute("UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL")
op.alter_column("users", "hashed_password", existing_type=sa.String(), nullable=False)

View file

@ -13,7 +13,12 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
# Null where there is no password at all. Somebody who arrived through
# single sign-on, or who only ever signs in with a code, has never chosen
# one — and storing a random string they can never guess made that
# indistinguishable from having one, so Settings asked them for a current
# password before it would let them set their first.
hashed_password = Column(String, nullable=True)
name = Column(String, nullable=False)
role = Column(String, default="user") # admin, moderator, user
# Which exam the learner is studying for; scopes the bank they see.

View file

@ -183,7 +183,11 @@ async def login(login_data: LoginRequest, db: Session = Depends(get_db), request
email_normalized = login_data.email.lower().strip()
user = db.query(User).filter(User.email == email_normalized).first()
if not user or not verify_password(login_data.password, user.hashed_password):
# An account with no password is not an account with the wrong password,
# but it is told the same thing: which accounts have one is not a question
# this endpoint answers.
if not user or not user.hashed_password or not verify_password(
login_data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid email or password")
# Check email verification — skip for users without any verification record (legacy/seeded)
@ -345,16 +349,22 @@ def save_user_settings(
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)):
return current_user
return UserResponse.of(current_user)
@router.put("/me")
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
if data.new_password:
if not data.current_password:
raise HTTPException(status_code=400, detail="Current password required to set a new one")
if not verify_password(data.current_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
# A first password is not a change of one. Somebody who signed in with
# a code or through single sign-on has none to confirm, and asking for
# it locked them out of ever setting one — the only way through was
# "forgot password", which is a strange thing to click when you never
# had one.
if current_user.hashed_password:
if not data.current_password:
raise HTTPException(status_code=400, detail="Current password required to set a new one")
if not verify_password(data.current_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
current_user.hashed_password = get_password_hash(data.new_password)
@ -457,7 +467,9 @@ async def sso_callback(request: Request, db: Session = Depends(get_db)):
if not user:
user = User(
email=email,
hashed_password=get_password_hash(secrets.token_urlsafe(32)), # random password — SSO users don't use it
# No password rather than one nobody knows. A random string here
# reads as "has a password" everywhere that asks.
hashed_password=None,
name=name,
role="user",
)

View file

@ -20,10 +20,21 @@ class UserResponse(BaseModel):
role: str
is_unthrottled: int = 0
created_at: datetime
# Whether there is one at all, never anything about it. Settings has to say
# "Set a password" or "Change password", and it cannot tell from the outside.
has_password: bool = False
class Config:
from_attributes = True
@staticmethod
def of(user) -> "UserResponse":
return UserResponse(**{
"id": user.id, "email": user.email, "name": user.name, "role": user.role,
"is_unthrottled": user.is_unthrottled or 0, "created_at": user.created_at,
"has_password": bool(user.hashed_password),
})
class Token(BaseModel):
access_token: str

View file

@ -24,6 +24,10 @@ function Section({ title, description, children }) {
}
function ProfileSection({ user }) {
// Whether there is one at all never anything about it. Reported by the
// server, because nothing on this side can tell an account with no password
// from one whose password it simply does not know.
const hasPassword = user?.has_password !== false
const [name, setName] = useState(user?.name || '')
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
@ -39,7 +43,13 @@ function ProfileSection({ user }) {
if (newPassword && newPassword.length < 8) return setError('Password must be at least 8 characters')
const payload = {}
if (name !== user.name) payload.name = name
if (newPassword) { payload.current_password = currentPassword; payload.new_password = newPassword }
// A first password has none to confirm. Somebody who arrived through
// single sign-on, or who only ever signs in with a code, has never chosen
// one asking for the current one locked them out of setting their first.
if (newPassword) {
payload.new_password = newPassword
if (hasPassword) payload.current_password = currentPassword
}
if (!Object.keys(payload).length) return setError('No changes to save')
setLoading(true)
try {
@ -71,18 +81,25 @@ function ProfileSection({ user }) {
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<p style={{ fontWeight: 600, fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: 12 }}>
Change Password <span style={{ fontWeight: 400 }}>(leave blank to keep current)</span>
{hasPassword ? 'Change password' : 'Set a password'}{' '}
<span style={{ fontWeight: 400 }}>
{hasPassword
? '(leave blank to keep current)'
: '— optional. You can keep signing in with a code or single sign-on.'}
</span>
</p>
{hasPassword && (
<div className="form-group">
<label>Current Password</label>
<input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} placeholder="Required to change password" />
</div>
)}
<div className="form-group">
<label>Current Password</label>
<input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} placeholder="Required to change password" />
</div>
<div className="form-group">
<label>New Password</label>
<label>{hasPassword ? 'New Password' : 'Password'}</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="At least 8 characters" />
</div>
<div className="form-group">
<label>Confirm New Password</label>
<label>{hasPassword ? 'Confirm New Password' : 'Confirm Password'}</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
</div>
<button className="btn btn-primary" type="submit" disabled={loading}>{loading ? 'Saving...' : 'Save Changes'}</button>