feat: add bearer-authenticated password change API
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Add POST /api/v1/password endpoint accepting a JSON body (username, old_password, new_password) protected by a shared Bearer token (CHANGE_API_SECRET / -change-api-secret). It verifies the current password via an LDAP bind before applying the change, matching the alps "password" plugin bearer flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dda090938e
commit
37edbbb9b6
3 changed files with 262 additions and 0 deletions
132
change.go
132
change.go
|
|
@ -1,12 +1,29 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// changeAPISecret is the shared Bearer token protecting the JSON password change
|
||||||
|
// API (POST /api/v1/password). If empty, the API is disabled.
|
||||||
|
var changeAPISecret string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if val, ok := os.LookupEnv("CHANGE_API_SECRET"); ok {
|
||||||
|
changeAPISecret = val
|
||||||
|
}
|
||||||
|
|
||||||
|
flag.StringVar(&changeAPISecret, "change-api-secret", changeAPISecret, "Bearer token protecting the JSON password change API")
|
||||||
|
}
|
||||||
|
|
||||||
func checkPasswdConstraint(password string) error {
|
func checkPasswdConstraint(password string) error {
|
||||||
if len(password) < 12 {
|
if len(password) < 12 {
|
||||||
return errors.New("too short, please choose a password at least 12 characters long")
|
return errors.New("too short, please choose a password at least 12 characters long")
|
||||||
|
|
@ -118,3 +135,118 @@ func changePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
displayMsg(w, "Password successfully changed!", http.StatusOK)
|
displayMsg(w, "Password successfully changed!", http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// changePasswordAPIRequest is the JSON body accepted by changePasswordAPI.
|
||||||
|
// The field names match the default payload_mapping targets of the alps
|
||||||
|
// "password" plugin.
|
||||||
|
type changePasswordAPIRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
OldPassword string `json:"old_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkChangeAPIAuthorization validates the Bearer token of an incoming request
|
||||||
|
// against the configured shared secret in constant time.
|
||||||
|
func checkChangeAPIAuthorization(r *http.Request) bool {
|
||||||
|
if changeAPISecret == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(r.Header.Get("Authorization"))
|
||||||
|
if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return subtle.ConstantTimeCompare([]byte(fields[1]), []byte(changeAPISecret)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// changePasswordAPI changes a user's password through a JSON API protected by a
|
||||||
|
// Bearer token. Unlike the HTML form flow it is meant to be called by a trusted
|
||||||
|
// backend (e.g. the alps "password" plugin), but it still verifies the user's
|
||||||
|
// current password via an LDAP bind before applying the change.
|
||||||
|
func changePasswordAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !changeLimiter.Allow(remoteIP(r)) {
|
||||||
|
writeJSONError(w, http.StatusTooManyRequests, "Too many requests. Please try again later.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if changeAPISecret == "" {
|
||||||
|
writeJSONError(w, http.StatusServiceUnavailable, "Password change API is not configured.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !checkChangeAPIAuthorization(r) {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "Invalid or missing Bearer token.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req changePasswordAPIRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "Invalid JSON body.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "Please provide a valid username.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.OldPassword == "" || req.NewPassword == "" {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "Old and new passwords are required.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := checkPasswdConstraint(req.NewPassword); err != nil {
|
||||||
|
writeJSONError(w, http.StatusNotAcceptable, "The password you chose doesn't respect all constraints: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
internalErr := func(err error) {
|
||||||
|
log.Println(err)
|
||||||
|
writeJSONError(w, http.StatusInternalServerError, "Unable to process your request. Please try again later.")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := myLDAP.Connect()
|
||||||
|
if err != nil || conn == nil {
|
||||||
|
internalErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if err := conn.ServiceBind(); err != nil {
|
||||||
|
internalErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dn, err := conn.SearchDN(req.Username, true)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
// User not found: perform a dummy bind to prevent username enumeration via timing.
|
||||||
|
conn.Bind("cn=dummy,"+myLDAP.BaseDN, req.OldPassword)
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "Invalid login or password.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.Bind(dn, req.OldPassword); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "Invalid login or password.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.ChangePassword(dn, req.NewPassword); err != nil {
|
||||||
|
internalErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Password changed via API for %s", dn)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Password successfully changed"})
|
||||||
|
}
|
||||||
|
|
|
||||||
129
change_test.go
129
change_test.go
|
|
@ -1,6 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
@ -31,3 +34,129 @@ func TestCheckPasswdConstraint(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestChangePasswordAPI exercises the request-validation paths of the JSON
|
||||||
|
// password change API that return before any LDAP connection is attempted.
|
||||||
|
func TestChangePasswordAPI(t *testing.T) {
|
||||||
|
const secret = "s3cr3t-token"
|
||||||
|
|
||||||
|
// Restore the global secret after the test.
|
||||||
|
orig := changeAPISecret
|
||||||
|
t.Cleanup(func() { changeAPISecret = orig })
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
secret string // value of changeAPISecret for this case
|
||||||
|
authHeader string // Authorization header ("" means none)
|
||||||
|
body string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "API disabled",
|
||||||
|
secret: "",
|
||||||
|
authHeader: "Bearer " + secret,
|
||||||
|
body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`,
|
||||||
|
wantStatus: http.StatusServiceUnavailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing token",
|
||||||
|
secret: secret,
|
||||||
|
authHeader: "",
|
||||||
|
body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`,
|
||||||
|
wantStatus: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong token",
|
||||||
|
secret: secret,
|
||||||
|
authHeader: "Bearer nope",
|
||||||
|
body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`,
|
||||||
|
wantStatus: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed authorization header",
|
||||||
|
secret: secret,
|
||||||
|
authHeader: secret,
|
||||||
|
body: `{"username":"alice","old_password":"OldPass12345","new_password":"NewPass12345"}`,
|
||||||
|
wantStatus: http.StatusUnauthorized,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid JSON",
|
||||||
|
secret: secret,
|
||||||
|
authHeader: "Bearer " + secret,
|
||||||
|
body: `{not json`,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing username",
|
||||||
|
secret: secret,
|
||||||
|
authHeader: "Bearer " + secret,
|
||||||
|
body: `{"old_password":"OldPass12345","new_password":"NewPass12345"}`,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing passwords",
|
||||||
|
secret: secret,
|
||||||
|
authHeader: "Bearer " + secret,
|
||||||
|
body: `{"username":"alice"}`,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weak new password",
|
||||||
|
secret: secret,
|
||||||
|
authHeader: "Bearer " + secret,
|
||||||
|
body: `{"username":"alice","old_password":"OldPass12345","new_password":"short"}`,
|
||||||
|
wantStatus: http.StatusNotAcceptable,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
changeAPISecret = tt.secret
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/password", strings.NewReader(tt.body))
|
||||||
|
if tt.authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", tt.authHeader)
|
||||||
|
}
|
||||||
|
// Unique remote address per case so the shared rate limiter never trips.
|
||||||
|
req.RemoteAddr = fmt.Sprintf("192.0.2.%d:1234", i+1)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
changePasswordAPI(w, req)
|
||||||
|
|
||||||
|
if w.Code != tt.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d (body: %s)", w.Code, tt.wantStatus, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckChangeAPIAuthorization(t *testing.T) {
|
||||||
|
orig := changeAPISecret
|
||||||
|
t.Cleanup(func() { changeAPISecret = orig })
|
||||||
|
changeAPISecret = "s3cr3t-token"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"valid", "Bearer s3cr3t-token", true},
|
||||||
|
{"case-insensitive scheme", "bearer s3cr3t-token", true},
|
||||||
|
{"wrong token", "Bearer wrong", false},
|
||||||
|
{"missing", "", false},
|
||||||
|
{"no scheme", "s3cr3t-token", false},
|
||||||
|
{"wrong scheme", "Basic s3cr3t-token", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/password", nil)
|
||||||
|
if tt.header != "" {
|
||||||
|
req.Header.Set("Authorization", tt.header)
|
||||||
|
}
|
||||||
|
if got := checkChangeAPIAuthorization(req); got != tt.want {
|
||||||
|
t.Errorf("checkChangeAPIAuthorization(%q) = %v, want %v", tt.header, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
1
main.go
1
main.go
|
|
@ -241,6 +241,7 @@ func main() {
|
||||||
http.HandleFunc(fmt.Sprintf("GET %s/style.css", baseURL), serveStyleCSS)
|
http.HandleFunc(fmt.Sprintf("GET %s/style.css", baseURL), serveStyleCSS)
|
||||||
http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", baseURL), serveAltchaChallenge)
|
http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", baseURL), serveAltchaChallenge)
|
||||||
http.HandleFunc(fmt.Sprintf("%s/{$}", baseURL), changePassword)
|
http.HandleFunc(fmt.Sprintf("%s/{$}", baseURL), changePassword)
|
||||||
|
http.HandleFunc(fmt.Sprintf("POST %s/api/v1/password", baseURL), changePasswordAPI)
|
||||||
http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", baseURL), addyAliasAPI)
|
http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", baseURL), addyAliasAPI)
|
||||||
http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", baseURL), addyAliasAPIDelete)
|
http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", baseURL), addyAliasAPIDelete)
|
||||||
http.HandleFunc(fmt.Sprintf("%s/auth", baseURL), httpBasicAuth)
|
http.HandleFunc(fmt.Sprintf("%s/auth", baseURL), httpBasicAuth)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue