Allow logging in via email

This commit is contained in:
binwiederhier
2026-07-16 21:42:42 +02:00
parent b55e78a918
commit 24bc50b585
17 changed files with 452 additions and 199 deletions
+3
View File
@@ -110,6 +110,7 @@ var (
apiUsersPath = "/v1/users"
apiUsersAccessPath = "/v1/users/access"
apiAccountPath = "/v1/account"
apiAccountLoginPath = "/v1/account/login"
apiAccountTokenPath = "/v1/account/token"
apiAccountPasswordPath = "/v1/account/password"
apiAccountSettingsPath = "/v1/account/settings"
@@ -579,6 +580,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {
return s.ensureUser(s.handleAccountPasswordChange)(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountLoginPath {
return s.ensureUser(s.withAccountSync(s.handleAccountLogin))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath {
return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v)
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath {
+18
View File
@@ -268,6 +268,24 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
return s.writeJSON(w, newSuccessResponse())
}
// handleAccountLogin authenticates a username-or-email + password (via the ensureUser wrapper's
// Basic Auth), mints a session token, and returns it together with the canonical username. Unlike
// the token endpoint (which exists to mint arbitrary API tokens), this endpoint's job is to log a
// user in, so it also reports who they are (the identifier they typed may be a primary email).
func (s *Server) handleAccountLogin(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
logvr(v, r).Tag(tagAccount).Info("Logging in user %s", u.Name)
token, err := s.userManager.CreateToken(u.ID, "", time.Now().Add(tokenExpiryDuration), v.IP(), false)
if err != nil {
return err
}
response := &apiAccountLoginResponse{
Token: token.Value,
Username: u.Name,
}
return s.writeJSON(w, response)
}
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
if err != nil {
+16
View File
@@ -224,6 +224,22 @@ func canLogin(t *testing.T, s *Server, username, password string) bool {
return rr.Code == 200
}
func TestAccount_LoginByPrimaryEmail(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
// Basic Auth works with either the username or the verified primary email
require.True(t, canLogin(t, s, "ben", "ben"))
require.True(t, canLogin(t, s, "ben@example.com", "ben"))
// ...but not with the wrong password or an unknown email
require.False(t, canLogin(t, s, "ben@example.com", "wrong"))
require.False(t, canLogin(t, s, "nobody@example.com", "ben"))
})
}
func TestAccount_PasswordReset_ByUsername(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
+52
View File
@@ -55,6 +55,58 @@ func TestAccount_Signup_Success(t *testing.T) {
})
}
func TestAccount_Login_Success(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "phil@example.com"))
// Login by username returns a token and the canonical username
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
resp, _ := util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
require.Equal(t, "phil", resp.Username)
// The returned token actually authenticates
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BearerAuth(resp.Token),
})
require.Equal(t, 200, rr.Code)
// Login by primary email returns the canonical username, not the email that was typed
rr = request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil@example.com", "mypass"),
})
require.Equal(t, 200, rr.Code)
resp, _ = util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
require.Equal(t, "phil", resp.Username)
})
}
func TestAccount_Login_InvalidCredentials(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil", "wrongpass"),
})
require.Equal(t, 401, rr.Code)
})
}
func TestAccount_Signup_UserExists(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
+8
View File
@@ -217,6 +217,14 @@ type apiAccountTokenResponse struct {
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
}
// apiAccountLoginResponse is the body of POST /v1/account/login: it authenticates a
// username-or-email + password, mints a session token, and returns the token together with the
// canonical username (which may differ from the identifier the user typed, e.g. a primary email).
type apiAccountLoginResponse struct {
Token string `json:"token"`
Username string `json:"username"`
}
type apiAccountPhoneNumberVerifyRequest struct {
Number string `json:"number"`
Channel string `json:"channel"`