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
129
change_test.go
129
change_test.go
|
|
@ -1,6 +1,9 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue