Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC single sign-on, API keys, revocable sessions), an encrypted SQLite database, resource logins, alerts and /metrics, mail egress management with DKIM keys, and the .env loader. Split out of the original single repository; depends on Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip and Bamboo together live in e2e/. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
347 lines
9.0 KiB
Go
347 lines
9.0 KiB
Go
package hammerhead
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Hammerhead acts as an OpenID Connect relying party using the authorization
|
|
// code flow with PKCE. Identity comes from the provider's userinfo endpoint,
|
|
// fetched over TLS with the access token we just received from the token endpoint.
|
|
|
|
type oidcDoc struct {
|
|
auth, token, userinfo string
|
|
fetched time.Time
|
|
}
|
|
|
|
type oauthState struct {
|
|
idp, verifier, ret string
|
|
expires time.Time
|
|
}
|
|
|
|
var oidcClient = &http.Client{Timeout: 10 * time.Second}
|
|
|
|
func (s *Server) discover(idp *IdP) (*oidcDoc, error) {
|
|
s.mu.Lock()
|
|
d := s.oidc[idp.ID]
|
|
s.mu.Unlock()
|
|
if d != nil && time.Since(d.fetched) < time.Hour {
|
|
return d, nil
|
|
}
|
|
resp, err := oidcClient.Get(strings.TrimRight(idp.Issuer, "/") + "/.well-known/openid-configuration")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("discovery returned %d", resp.StatusCode)
|
|
}
|
|
var doc struct {
|
|
Auth string `json:"authorization_endpoint"`
|
|
Token string `json:"token_endpoint"`
|
|
Userinfo string `json:"userinfo_endpoint"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil {
|
|
return nil, err
|
|
}
|
|
if doc.Auth == "" || doc.Token == "" || doc.Userinfo == "" {
|
|
return nil, errors.New("provider discovery document is missing endpoints")
|
|
}
|
|
d = &oidcDoc{auth: doc.Auth, token: doc.Token, userinfo: doc.Userinfo, fetched: time.Now()}
|
|
s.mu.Lock()
|
|
s.oidc[idp.ID] = d
|
|
s.mu.Unlock()
|
|
return d, nil
|
|
}
|
|
|
|
// baseURL is where browsers reach hammerhead.
|
|
func (s *Server) baseURL(r *http.Request) string {
|
|
s.mu.Lock()
|
|
u := s.st.Settings.DashboardURL
|
|
s.mu.Unlock()
|
|
if u != "" {
|
|
return u
|
|
}
|
|
scheme := "http"
|
|
if r.TLS != nil {
|
|
scheme = "https"
|
|
}
|
|
return scheme + "://" + r.Host
|
|
}
|
|
|
|
func safeReturn(p string) string {
|
|
if p == "" || p[0] != '/' || strings.HasPrefix(p, "//") || strings.HasPrefix(p, "/\\") {
|
|
return "/"
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
|
|
s.mu.Lock()
|
|
var idp *IdP
|
|
for _, p := range s.st.IdPs {
|
|
if p.ID == r.PathValue("id") {
|
|
c := *p
|
|
idp = &c
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
if idp == nil {
|
|
http.Error(w, "unknown provider", 404)
|
|
return
|
|
}
|
|
doc, err := s.discover(idp)
|
|
if err != nil {
|
|
http.Error(w, "identity provider unreachable: "+err.Error(), 502)
|
|
return
|
|
}
|
|
state, verifier := randHex(16), randHex(32)
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
s.mu.Lock()
|
|
now := time.Now()
|
|
for k, v := range s.oauth {
|
|
if now.After(v.expires) {
|
|
delete(s.oauth, k)
|
|
}
|
|
}
|
|
s.oauth[state] = oauthState{idp: idp.ID, verifier: verifier, ret: safeReturn(r.URL.Query().Get("return")), expires: now.Add(10 * time.Minute)}
|
|
s.mu.Unlock()
|
|
scopes := idp.Scopes
|
|
if scopes == "" {
|
|
scopes = "openid email profile"
|
|
}
|
|
q := url.Values{
|
|
"response_type": {"code"},
|
|
"client_id": {idp.ClientID},
|
|
"redirect_uri": {s.baseURL(r) + "/auth/oidc/callback"},
|
|
"scope": {scopes},
|
|
"state": {state},
|
|
"code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])},
|
|
"code_challenge_method": {"S256"},
|
|
}
|
|
sep := "?"
|
|
if strings.Contains(doc.auth, "?") {
|
|
sep = "&"
|
|
}
|
|
http.Redirect(w, r, doc.auth+sep+q.Encode(), http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
s.mu.Lock()
|
|
st, found := s.oauth[q.Get("state")]
|
|
delete(s.oauth, q.Get("state"))
|
|
var idp *IdP
|
|
if found && time.Now().Before(st.expires) {
|
|
for _, p := range s.st.IdPs {
|
|
if p.ID == st.idp {
|
|
c := *p
|
|
idp = &c
|
|
}
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
if idp == nil {
|
|
http.Error(w, "sign-in expired, please try again", 400)
|
|
return
|
|
}
|
|
if e := q.Get("error"); e != "" {
|
|
http.Error(w, "identity provider said: "+e, 400)
|
|
return
|
|
}
|
|
email, name, sub, err := s.oidcExchange(r, idp, q.Get("code"), st.verifier)
|
|
if err != nil {
|
|
http.Error(w, "sign-in failed: "+err.Error(), 400)
|
|
return
|
|
}
|
|
uid, err := s.oidcUser(idp, email, name, sub)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 403)
|
|
return
|
|
}
|
|
s.startSession(w, r, uid)
|
|
s.audit("", email, "login", "oidc:"+idp.Name)
|
|
http.Redirect(w, r, st.ret, http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) oidcExchange(r *http.Request, idp *IdP, code, verifier string) (email, name, sub string, err error) {
|
|
doc, err := s.discover(idp)
|
|
if err != nil {
|
|
return
|
|
}
|
|
resp, err := oidcClient.PostForm(doc.token, url.Values{
|
|
"grant_type": {"authorization_code"}, "code": {code},
|
|
"redirect_uri": {s.baseURL(r) + "/auth/oidc/callback"},
|
|
"client_id": {idp.ClientID}, "client_secret": {idp.ClientSecret}, "code_verifier": {verifier},
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
var tok struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
if resp.StatusCode != 200 || json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok) != nil || tok.AccessToken == "" {
|
|
err = fmt.Errorf("token exchange failed (%d)", resp.StatusCode)
|
|
return
|
|
}
|
|
req, _ := http.NewRequest("GET", doc.userinfo, nil)
|
|
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
|
|
ui, err := oidcClient.Do(req)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer ui.Body.Close()
|
|
var claims struct {
|
|
Sub string `json:"sub"`
|
|
Email string `json:"email"`
|
|
EmailVerified any `json:"email_verified"`
|
|
Name string `json:"name"`
|
|
PreferredUsername string `json:"preferred_username"`
|
|
}
|
|
if ui.StatusCode != 200 || json.NewDecoder(io.LimitReader(ui.Body, 1<<20)).Decode(&claims) != nil {
|
|
err = fmt.Errorf("userinfo failed (%d)", ui.StatusCode)
|
|
return
|
|
}
|
|
// some providers send "true" as a string, some omit it; only an explicit false is refused
|
|
if v, ok := claims.EmailVerified.(bool); ok && !v {
|
|
err = errors.New("your email address is not verified at the identity provider")
|
|
return
|
|
}
|
|
if v, ok := claims.EmailVerified.(string); ok && strings.EqualFold(v, "false") {
|
|
err = errors.New("your email address is not verified at the identity provider")
|
|
return
|
|
}
|
|
if claims.Sub == "" || claims.Email == "" {
|
|
err = errors.New("the identity provider did not return an email address")
|
|
return
|
|
}
|
|
name = claims.Name
|
|
if name == "" {
|
|
name = claims.PreferredUsername
|
|
}
|
|
return strings.ToLower(claims.Email), name, claims.Sub, nil
|
|
}
|
|
|
|
func (s *Server) oidcUser(idp *IdP, email, name, sub string) (string, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, u := range s.st.Users {
|
|
if u.IdP == idp.ID && u.IdPSub == sub {
|
|
return u.ID, nil
|
|
}
|
|
}
|
|
if len(idp.AllowedDomains) > 0 {
|
|
okDomain := false
|
|
for _, d := range idp.AllowedDomains {
|
|
if strings.HasSuffix(email, "@"+strings.ToLower(strings.TrimSpace(d))) {
|
|
okDomain = true
|
|
}
|
|
}
|
|
if !okDomain {
|
|
return "", errors.New("your email domain is not allowed to sign in here")
|
|
}
|
|
}
|
|
if u := s.userByEmail(email); u != nil { // link an existing account by verified email
|
|
if u.IdP == "" {
|
|
u.IdP, u.IdPSub = idp.ID, sub
|
|
s.save()
|
|
}
|
|
return u.ID, nil
|
|
}
|
|
if !idp.AutoProvision {
|
|
return "", errors.New("no account exists for " + email + "; ask an administrator to invite you")
|
|
}
|
|
if name == "" {
|
|
name = email
|
|
}
|
|
u := &User{ID: newID(), Email: email, Name: name, IdP: idp.ID, IdPSub: sub, Created: time.Now()}
|
|
s.st.Users = append(s.st.Users, u)
|
|
if s.findOrg(idp.OrgID) != nil {
|
|
role := s.findRole(idp.RoleID)
|
|
if role == nil || role.OrgID != idp.OrgID {
|
|
for _, r := range s.st.Roles {
|
|
if r.OrgID == idp.OrgID && r.Name == "Member" && r.Builtin {
|
|
role = r
|
|
}
|
|
}
|
|
}
|
|
if role != nil {
|
|
s.st.Members = append(s.st.Members, &Member{UserID: u.ID, OrgID: idp.OrgID, RoleID: role.ID})
|
|
}
|
|
}
|
|
s.save()
|
|
return u.ID, nil
|
|
}
|
|
|
|
// ---------- admin CRUD ----------
|
|
|
|
func (s *Server) listIdPs(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := []IdP{}
|
|
for _, i := range s.st.IdPs {
|
|
c := *i
|
|
c.ClientSecret = ""
|
|
out = append(out, c)
|
|
}
|
|
writeJSON(w, 200, out)
|
|
}
|
|
|
|
func (s *Server) saveIdP(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
var in IdP
|
|
if !decode(w, r, &in) {
|
|
return
|
|
}
|
|
in.Issuer = strings.TrimRight(strings.TrimSpace(in.Issuer), "/")
|
|
in.Name = strings.TrimSpace(in.Name)
|
|
if in.Name == "" || in.Issuer == "" || in.ClientID == "" {
|
|
fail(w, 400, errors.New("name, issuer URL and client ID are required"))
|
|
return
|
|
}
|
|
if !strings.HasPrefix(in.Issuer, "https://") && !strings.HasPrefix(in.Issuer, "http://") {
|
|
fail(w, 400, errors.New("issuer must be a URL"))
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if id := r.PathValue("id"); id != "" {
|
|
for _, e := range s.st.IdPs {
|
|
if e.ID == id {
|
|
if in.ClientSecret == "" {
|
|
in.ClientSecret = e.ClientSecret
|
|
}
|
|
in.ID = id
|
|
*e = in
|
|
delete(s.oidc, id)
|
|
s.save()
|
|
ok(w)
|
|
return
|
|
}
|
|
}
|
|
fail(w, 404, errors.New("no such provider"))
|
|
return
|
|
}
|
|
in.ID = newID()
|
|
s.st.IdPs = append(s.st.IdPs, &in)
|
|
s.save()
|
|
ok(w)
|
|
}
|
|
|
|
func (s *Server) deleteIdP(w http.ResponseWriter, r *http.Request, p *principal) {
|
|
id := r.PathValue("id")
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.st.IdPs = filter(s.st.IdPs, func(i *IdP) bool { return i.ID != id })
|
|
s.save()
|
|
ok(w)
|
|
}
|