mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Allow logging in via email
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user