Compare commits

..

5 Commits

23 changed files with 1083 additions and 220 deletions

70
app/ipasso/cookies.go Normal file
View File

@@ -0,0 +1,70 @@
package main
import (
"context"
"git.thequux.com/thequux/ipasso/backend"
"go.uber.org/zap"
"net/http"
)
type loginStateMw struct {
handler http.Handler
}
type Session struct {
State LoginState
Session *backend.Session
Cache *backend.SessionCache
}
func (l loginStateMw) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
var cookie *http.Cookie
var session Session
session.State = LS_Unknown
for _, cookie = range req.Cookies() {
// TODO: make host configurable
if cookie.Name != "IPASSO_SID" {
continue
}
zap.L().Debug("Session ID found in req", zap.String(cookie.Name, cookie.Value))
sid := cookie.Value
if sid == "[logout]" {
session.State = LS_LoggedOut
break
}
bsession, bcache, err := backend.SessionStore.GetSession(ctx, sid)
if err != nil {
zap.L().Warn("Failed to fetch cookie", zap.Error(err))
// cookie was invalid
session.State = LS_Invalid
} else {
session.State = LS_Valid
session.Session = &bsession
session.Cache = bcache
}
break
}
ctx = context.WithValue(ctx, "ipa.Session", &session)
l.handler.ServeHTTP(w, req.WithContext(ctx))
}
func GetSession(ctx context.Context, loadCache bool) *Session {
session := ctx.Value("ipa.Session").(*Session)
if session != nil && session.Session != nil && loadCache && (session.Cache == nil || !session.Cache.Valid) {
cache, err := buildSessionCache(session.Session, nil)
if err != nil {
zap.L().Warn("Failed to build session cache", zap.Error(err))
} else {
session.Cache = &cache
}
} else {
zap.L().Debug("Not rebuilding session cache", zap.Any("session", session))
}
return session
}
func LoginStateMw(handler http.Handler) http.Handler {
return loginStateMw{handler}
}

View File

@@ -20,6 +20,12 @@ func init() {
})
}
type envdocArgs struct {
Headers map[string]string
ProcessEnv map[string]string
ReqURL string
}
func dumpEnv(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")

View File

@@ -3,7 +3,8 @@ package main
import (
"errors"
"flag"
"git.thequux.com/thequux/ipasso/sso-proxy/backend"
"fmt"
"git.thequux.com/thequux/ipasso/backend"
"git.thequux.com/thequux/ipasso/util"
"git.thequux.com/thequux/ipasso/util/genpool"
"git.thequux.com/thequux/ipasso/util/startup"
@@ -35,8 +36,10 @@ var (
)
var (
ErrNoValidServer = errors.New("no valid server")
ldapRootLogger, ldapPoolLogger *zap.Logger
ErrNoValidServer = errors.New("no valid server")
ErrLoginFailed = errors.New("login failed")
ldapRootLogger *zap.Logger
ldapPoolLogger *zap.Logger
)
func init() {
@@ -67,6 +70,9 @@ func init() {
ldapRootLogger.Debug("Configured LDAP rootDN", zap.String("rootDN", *ldapRootDN))
}
// configure username basedn
ldapUserBase = "cn=users,cn=accounts," + *ldapRootDN
krb5Config, err := config.Load(*krb5conf)
var realmSource string = ""
if err != nil {
@@ -199,7 +205,7 @@ func buildSessionCache(b *backend.Session, entry *ldap.Entry) (cache backend.Ses
} else if attr.Name == "givenName" && len(attr.Values) > 0 {
cache.GivenName = strings.Join(attr.Values, " ")
} else if attr.Name == "sn" {
cache.SurName = strings.Join(attr.Values, " ")
cache.FamilyName = strings.Join(attr.Values, " ")
} else if attr.Name == "mail" && len(attr.Values) > 0 {
cache.Email = attr.Values[0]
} else if attr.Name == "memberOf" {
@@ -260,7 +266,31 @@ func getUserByPrincipal(principal string) (*ldap.Entry, error) {
func getUserByDn(dn string) (*ldap.Entry, error) {
return ldapSearchSingle(&ldap.SearchRequest{
BaseDN: dn,
Filter: "(objectClass=top)",
Scope: ldap.ScopeBaseObject,
Attributes: interestingUserAttr,
})
}
func getUserByUsernamePassword(username, password string) (*ldap.Entry, error) {
server := selectServer()
if server == nil {
return nil, ErrNoValidServer
}
conn, err := ldap.DialURL(server.Url)
if err != nil {
return nil, err
}
defer conn.Close()
// try bind
userDN := fmt.Sprintf("uid=%s,%s", username, ldapUserBase)
//ldapRootLogger.Debug("Attempting bind login", zap.String("user", userDN), zap.String("password", password))
err = conn.Bind(userDN, password)
if err != nil {
return nil, ErrLoginFailed
}
//ldapRootLogger.Info("Successfully authorized using simple bind", zap.String("user", username), zap.String("dn", userDN))
return getUserByDn(userDN)
}

View File

@@ -1,21 +1,65 @@
package main
import (
"git.thequux.com/thequux/ipasso/sso-proxy/backend"
"errors"
"flag"
"git.thequux.com/thequux/ipasso/backend"
"git.thequux.com/thequux/ipasso/resources"
"git.thequux.com/thequux/ipasso/util/startup"
"github.com/go-ldap/ldap/v3"
"github.com/julienschmidt/httprouter"
hydra "github.com/ory/hydra-client-go/v2"
"go.uber.org/zap"
"io"
"log"
"net/http"
"net/http/fcgi"
"strconv"
"strings"
"time"
)
var hydraConfig = hydra.NewConfiguration()
var (
hydraAdmin = flag.String("hydra-admin", "http://localhost:4445", "URL at which the Hydra admin API can be found")
hydraClient *hydra.APIClient
oauth2Logger *zap.Logger
)
func init() {
startup.Routes.Add(func(router *httprouter.Router) {
router.Handler("GET", "/login", LoginStateMw(http.HandlerFunc(loginPage)))
router.Handler("GET", "/consent", LoginStateMw(http.HandlerFunc(consentPage)))
router.HandlerFunc("POST", "/login/krb5", loginKrb)
router.HandlerFunc("POST", "/login/password", loginPassword)
})
startup.Logger.Add(func() {
oauth2Logger = zap.L().Named("oauth2")
})
startup.PostFlags.Add(func() {
hydraConfig.Servers = []hydra.ServerConfiguration{
{
URL: *hydraAdmin,
},
}
hydraClient = hydra.NewAPIClient(hydraConfig)
})
}
func loginPage(w http.ResponseWriter, r *http.Request) {
if finishOauth2(w, r, nil, true) {
return
}
f, err := resources.StaticFiles.Open("login/index.html")
if err != nil {
ReportError(w, err)
}
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, f)
}
func loginKrb(w http.ResponseWriter, r *http.Request) {
@@ -23,7 +67,6 @@ func loginKrb(w http.ResponseWriter, r *http.Request) {
// The fact that we got here implies that the login succeeded
env := fcgi.ProcessEnv(r)
user := env["GSS_NAME"]
uid := strings.Split(user, "@")[0]
expirationTxt, hasExpiration := env["GSS_SESSION_EXPIRATION"]
var expiration time.Time
if hasExpiration {
@@ -41,21 +84,64 @@ func loginKrb(w http.ResponseWriter, r *http.Request) {
return
}
SessionID, err := datastore.NewSessionID()
if err != nil {
ReportError(w, err)
return
}
entry, err := getUserByPrincipal(user)
if err != nil {
ReportError(w, err)
return
}
finishLogin(w, r, entry, &expiration)
}
func loginPassword(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
ReportError(w, err)
}
user := r.Form.Get("user")
password := r.Form.Get("password")
if user == "" || password == "" {
ldapRootLogger.Debug("Missing value")
w.WriteHeader(http.StatusUnauthorized)
return
}
entry, err := getUserByUsernamePassword(user, password)
if errors.Is(err, ErrLoginFailed) {
w.WriteHeader(http.StatusUnauthorized)
return
} else if err != nil {
ReportError(w, err)
return
}
finishLogin(w, r, entry, nil)
}
func finishLogin(w http.ResponseWriter, req *http.Request, entry *ldap.Entry, expiration *time.Time) {
SessionID, err := backend.NewSessionID(req.Context(), backend.SessionStore)
if err != nil {
ReportError(w, err)
return
}
var uid string
for _, attr := range entry.Attributes {
if attr.Name == "uid" && len(attr.Values) > 0 {
uid = attr.Values[0]
}
}
// TODO: make configurable
if expiration == nil {
defExpiration := time.Now().Add(time.Hour * 14)
expiration = &defExpiration
}
session := backend.Session{
SessionID: SessionID,
Expiration: expiration,
Expiration: *expiration,
UserID: uid,
LdapDN: entry.DN,
}
@@ -64,5 +150,127 @@ func loginKrb(w http.ResponseWriter, r *http.Request) {
ReportError(w, err)
return
}
_ = datastore.PutSession(session, sessionCache)
_ = backend.SessionStore.PutSession(req.Context(), session, sessionCache)
// TODO: set cookies, return success
cookie := http.Cookie{
Name: "IPASSO_SID",
Value: SessionID,
Domain: req.Host,
Path: "/",
HttpOnly: true,
Secure: true,
Expires: session.Expiration,
SameSite: http.SameSiteStrictMode,
}
http.SetCookie(w, &cookie)
//cookie.Domain = "." + *domain
//http.SetCookie(w, &cookie)
// Handle OAuth2 if necessary
finishOauth2(w, req, &session, false)
}
// Finish OAuth2 processing if appropriate. Returns false if normal processing should continue
func finishOauth2(w http.ResponseWriter, req *http.Request, session *backend.Session, redirect bool) bool {
if session == nil {
beSession := GetSession(req.Context(), false)
if beSession == nil || beSession.Session == nil {
oauth2Logger.Debug("Not finishing oauth flow: no active session")
return false
}
session = beSession.Session
}
query := req.URL.Query()
if query.Has("login_challenge") {
challenge := query.Get("login_challenge")
acceptOAuth2LoginRequest := hydra.AcceptOAuth2LoginRequest{
Remember: hydra.PtrBool(true),
RememberFor: hydra.PtrInt64(int64(session.Expiration.Sub(time.Now()).Seconds())),
Subject: session.UserID,
}
oauth2Logger.Info("Accepting challenge", zap.Any("response", acceptOAuth2LoginRequest))
redirectTo, _, err := hydraClient.OAuth2Api.AcceptOAuth2LoginRequest(req.Context()).LoginChallenge(challenge).AcceptOAuth2LoginRequest(acceptOAuth2LoginRequest).Execute()
if err != nil {
oauth2Logger.Warn("AcceptOAuth2LoginRequest failed", zap.Error(err))
return false
}
if redirect {
http.Redirect(w, req, redirectTo.RedirectTo, http.StatusSeeOther)
} else {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(200)
_, _ = w.Write([]byte(redirectTo.RedirectTo))
}
oauth2Logger.Debug("Finishing oauth flow")
return true
}
oauth2Logger.Debug("Not finishing oauth flow: no challenge")
return false
}
type consentParams struct {
ConsentChallenge string
ConsentRequest string
}
type IdToken struct {
Name string `json:"name"`
Sub string `json:"sub"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
func consentPage(w http.ResponseWriter, req *http.Request) {
session := GetSession(req.Context(), true)
// We can assume that we've got a session, I think.
query := req.URL.Query()
consentChallenge := query.Get("consent_challenge")
ocr, _, err := hydraClient.OAuth2Api.GetOAuth2ConsentRequest(req.Context()).ConsentChallenge(consentChallenge).Execute()
if err != nil {
ReportError(w, err)
return
}
//reqJSON, _ := json.MarshalIndent(oAuth2ConsentRequest, "", " ")
//params := consentParams{
// ConsentRequest: string(reqJSON),
// ConsentChallenge: consentChallenge,
//}
//RenderPage(w, req, "consent", params)
// TODO: We should really have a consent page here, but in the mean time, not having it is OK
acr := hydra.AcceptOAuth2ConsentRequest{}
acr.Session = &hydra.AcceptOAuth2ConsentRequestSession{
AccessToken: map[string]string{},
IdToken: IdToken{
Name: session.Cache.DisplayName,
Sub: session.Session.UserID,
GivenName: session.Cache.GivenName,
FamilyName: session.Cache.FamilyName,
PreferredUsername: session.Session.UserID,
Email: session.Cache.Email,
EmailVerified: true,
},
}
acr.GrantScope = ocr.GetRequestedScope()
acr.GrantAccessTokenAudience = ocr.RequestedAccessTokenAudience
oauth2Logger.Info("Accepting consent", zap.Any("acr", acr))
redirectTo, _, err := hydraClient.OAuth2Api.AcceptOAuth2ConsentRequest(req.Context()).ConsentChallenge(consentChallenge).AcceptOAuth2ConsentRequest(acr).Execute()
if err != nil {
ReportError(w, err)
return
}
http.Redirect(w, req, redirectTo.RedirectTo, http.StatusSeeOther)
}

63
app/ipasso/loginState.go Normal file
View File

@@ -0,0 +1,63 @@
package main
import (
"git.thequux.com/thequux/ipasso/util/startup"
"github.com/julienschmidt/httprouter"
"net/http"
"time"
)
func init() {
startup.Routes.Add(func(router *httprouter.Router) {
router.Handler("GET", "/login/info", LoginStateMw(http.HandlerFunc(stateServlet)))
})
}
type LoginState string
var (
LS_Valid LoginState = "VALID"
LS_LoggedOut LoginState = "EXPLICIT_LOGOUT"
LS_Unknown LoginState = "UNKNOWN"
LS_Invalid LoginState = "INVALID"
)
type PublicLoginState struct {
State LoginState `json:"login_state"`
Expiration *time.Time `json:"expiration,omitempty"`
UserId string `json:"uid,omitempty"`
LdapDn string `json:"ldap_dn,omitempty"`
Groups []string `json:"groups,omitempty"`
DisplayName string `json:"display_name,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
Email string `json:"email,omitempty"`
}
func GetPublicState(s *Session) PublicLoginState {
ret := PublicLoginState{
State: s.State,
}
if s.Session != nil {
ret.Expiration = &s.Session.Expiration
ret.UserId = s.Session.UserID
ret.LdapDn = s.Session.LdapDN
}
if s.Cache != nil {
ret.Groups = s.Cache.Groups
ret.DisplayName = s.Cache.DisplayName
ret.GivenName = s.Cache.GivenName
ret.FamilyName = s.Cache.FamilyName
ret.Email = s.Cache.Email
}
return ret
}
func stateServlet(w http.ResponseWriter, req *http.Request) {
session := GetSession(req.Context(), true)
data := GetPublicState(session)
RenderPage(w, req, "status", data)
}

View File

@@ -1,31 +1,22 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"git.thequux.com/thequux/ipasso/resources"
"git.thequux.com/thequux/ipasso/sso-proxy/backend"
_ "git.thequux.com/thequux/ipasso/backend/all"
"git.thequux.com/thequux/ipasso/util/startup"
"github.com/CloudyKit/jet/v6"
"github.com/julienschmidt/httprouter"
"gitlab.com/jamietanna/content-negotiation-go"
"go.uber.org/zap"
"log"
"net"
"net/http"
"net/http/fcgi"
"os"
"strconv"
)
var (
domain = flag.String("domain", "thequux.com", "The base domain to enable SSO for")
listen = flag.String("listen", "0.0.0.0:80", "The address to listen on")
datastore backend.Backend = &backend.NullBackend{}
)
var (
@@ -58,89 +49,3 @@ func main() {
l.Info("Listening", zap.Stringer("addr", listener.Addr()))
log.Fatal(fcgi.Serve(listener, router))
}
type envdocArgs struct {
Headers map[string]string
ProcessEnv map[string]string
ReqURL string
}
// Try to continue negotiation via a www-authenticate header, if this is an error page.
func continueNegotate(w http.ResponseWriter, r *http.Request) bool {
procEnv := fcgi.ProcessEnv(r)
l := zap.L().Named("req")
l.Debug("Received request", zap.Any("env", procEnv))
rStatus, ok := procEnv["REDIRECT_STATUS"]
if ok && rStatus == "401" {
w.Header().Set("WWW-Authenticate", "Negotiate")
w.WriteHeader(401)
return true
}
return false
}
type RespErr struct {
Err string
Status string
}
func ReportError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "text/json")
w.WriteHeader(http.StatusInternalServerError)
js, err0 := json.Marshal(RespErr{Err: err.Error(), Status: "ERROR"})
if err0 != nil {
panic(err0)
}
_, _ = w.Write(js)
}
var negotiator = contentnegotiation.NewNegotiator("text/html", "text/plain", "application/json")
func RenderPage(w http.ResponseWriter, req *http.Request, template string, data interface{}) {
ctype, _, err := negotiator.Negotiate(req.Header.Get("Accept"))
if err != nil {
templateLogger.Warn("Negotiation failed",
zap.String("accept", req.Header.Get("Accept")),
zap.Error(err),
)
ReportError(w, err)
}
w.Header().Set("Content-Type", ctype.String())
var result []byte
var jetTemplate *jet.Template
if ctype.String() == "text/html" {
template = template + ".html"
jetTemplate, err = resources.Templates.HtmlTemplate(template)
} else if ctype.String() == "text/plain" {
template = template + ".txt"
jetTemplate, err = resources.Templates.TextTemplate(template)
} else if ctype.String() == "application/json" {
jetTemplate = nil
result, err = json.MarshalIndent(data, "", " ")
} else {
templateLogger.Warn("Negotiation returned something unexpected", zap.Stringer("negotiated", &ctype))
err = errors.New("unexpected negotiation result")
}
if err == nil && jetTemplate != nil && len(result) == 0 {
var builder bytes.Buffer
err := jetTemplate.Execute(&builder, nil, data)
if err != nil {
templateLogger.Warn("Failed to render template", zap.String("tmpl", template), zap.Error(err))
} else {
result = builder.Bytes()
}
}
if err == nil {
w.Header().Set("Content-Type", ctype.String())
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(result)), 10))
w.WriteHeader(200)
_, _ = w.Write(result)
} else {
ReportError(w, err)
}
}

102
app/ipasso/renderCommon.go Normal file
View File

@@ -0,0 +1,102 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"git.thequux.com/thequux/ipasso/resources"
"github.com/CloudyKit/jet/v6"
contentnegotiation "gitlab.com/jamietanna/content-negotiation-go"
"go.uber.org/zap"
"net/http"
"net/http/fcgi"
"strconv"
)
// Try to continue negotiation via a www-authenticate header, if this is an error page.
func continueNegotate(w http.ResponseWriter, r *http.Request) bool {
procEnv := fcgi.ProcessEnv(r)
l := zap.L().Named("req")
l.Debug("Received request", zap.Any("env", procEnv))
rStatus, ok := procEnv["REDIRECT_STATUS"]
if ok && rStatus == "401" {
w.Header().Set("WWW-Authenticate", "Negotiate")
w.WriteHeader(401)
return true
}
return false
}
type RespErr struct {
Err string
Status string
}
func ReportError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "text/json")
w.WriteHeader(http.StatusInternalServerError)
js, err0 := json.Marshal(RespErr{Err: err.Error(), Status: "ERROR"})
if err0 != nil {
panic(err0)
}
_, _ = w.Write(js)
}
var ctypes = [resources.ATComboCount]contentnegotiation.Negotiator{
contentnegotiation.NewNegotiator("application/json"),
contentnegotiation.NewNegotiator("text/html", "application/json"),
contentnegotiation.NewNegotiator("text/plain", "application/json"),
contentnegotiation.NewNegotiator("text/html", "text/plain", "application/json"),
}
//var negotiator = contentnegotiation.NewNegotiator("text/html", "text/plain", "application/json")
func RenderPage(w http.ResponseWriter, req *http.Request, template string, data interface{}) {
act := resources.Templates.GetContentTypes(template)
negotiator := ctypes[act]
ctype, _, err := negotiator.Negotiate(req.Header.Get("Accept"))
if err != nil {
templateLogger.Warn("Negotiate failed", zap.Error(err), zap.String("ctype", ctype.String()))
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusUnsupportedMediaType)
return
}
w.Header().Set("Content-Type", ctype.String())
var result []byte
var jetTemplate *jet.Template
if ctype.String() == "text/html" {
//template = template + ".html"
jetTemplate, err = resources.Templates.HtmlTemplate(template)
} else if ctype.String() == "text/plain" {
//template = template + ".txt"
jetTemplate, err = resources.Templates.TextTemplate(template)
} else if ctype.String() == "application/json" {
jetTemplate = nil
result, err = json.MarshalIndent(data, "", " ")
} else {
templateLogger.Warn("Negotiation returned something unexpected", zap.Stringer("negotiated", &ctype))
err = errors.New("unexpected negotiation result")
}
if err == nil && jetTemplate != nil && len(result) == 0 {
var builder bytes.Buffer
err := jetTemplate.Execute(&builder, nil, data)
if err != nil {
templateLogger.Warn("Failed to render template", zap.String("tmpl", template), zap.Error(err))
} else {
result = builder.Bytes()
}
}
if err == nil {
w.Header().Set("Content-Type", ctype.String())
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(result)), 10))
w.WriteHeader(200)
_, _ = w.Write(result)
} else {
ReportError(w, err)
}
}

12
backend/all/importAll.go Normal file
View File

@@ -0,0 +1,12 @@
// Shortcut to include all available backends
// Usage:
//
// ```
// import _ "git.thequux.com/thequux/ipasso/backend/all"
// ```
package all
import (
_ "git.thequux.com/thequux/ipasso/backend/null"
_ "git.thequux.com/thequux/ipasso/backend/redis"
)

71
backend/interface.go Normal file
View File

@@ -0,0 +1,71 @@
package backend
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"time"
)
type UserType string
var (
UtPerson UserType = "person"
UtHost UserType = "host"
UtService UserType = "service"
ErrTooManyAttempts error = errors.New("too many attempts")
ErrReservationSniped error = errors.New("session ID reservation expired and was sniped")
ErrBackendData error = errors.New("invalid data from backend")
)
// Session holds info about the logged-in user that won't change
type Session struct {
SessionID string
Expiration time.Time
UserID string
UserType UserType
LdapDN string
}
// SessionCache holds volatile information about the logged-in user
type SessionCache struct {
Valid bool
Groups []string
DisplayName string
GivenName string
FamilyName string
Email string
}
type Backend interface {
PutSession(ctx context.Context, session Session, cache SessionCache) error
GetSession(ctx context.Context, id string) (Session, *SessionCache, error)
EndSession(ctx context.Context, id string)
// ReserveSessionID attempts to reserve a session ID. If the ID was successfully reserved, return true
// A reserved session ID should time out no sooner than 60s from the reservation.
// Ergo, once reserved, a session ID should be used within 60s
ReserveSessionID(ctx context.Context, sessionID string) (bool, error)
DoMaintenance(ctx context.Context)
Ping(ctx context.Context) error
}
func NewSessionID(ctx context.Context, backend Backend) (string, error) {
var data = make([]byte, 18)
for i := 0; i < 10; i++ {
_, err := rand.Read(data[2:])
if err != nil {
return "", err
}
encoded := base64.URLEncoding.EncodeToString(data)
success, err := backend.ReserveSessionID(ctx, encoded)
if err != nil {
return "", err
}
if success {
return encoded, nil
}
}
return "", ErrTooManyAttempts
}

47
backend/null/null.go Normal file
View File

@@ -0,0 +1,47 @@
package backend
import (
"context"
"errors"
"git.thequux.com/thequux/ipasso/backend"
"go.uber.org/zap"
"net/url"
)
type NullBackend struct{}
func (n *NullBackend) PutSession(ctx context.Context, session backend.Session, cache backend.SessionCache) error {
//TODO implement me
zap.L().Info("Put session", zap.Any("session", session), zap.Any("cache", cache))
return nil
}
func (n *NullBackend) GetSession(ctx context.Context, id string) (backend.Session, *backend.SessionCache, error) {
//TODO implement me
return backend.Session{}, nil, errors.New("no such session")
}
func (n *NullBackend) EndSession(ctx context.Context, id string) {
//TODO implement me
}
func (n *NullBackend) ReserveSessionID(ctx context.Context, sessionID string) (bool, error) {
//TODO implement me
return false, nil
}
func (n *NullBackend) Ping(ctx context.Context) error {
//TODO implement me
return nil
}
func (n *NullBackend) DoMaintenance(ctx context.Context) {
//TODO implement me
}
func init() {
backend.RegisterBackendFactory("null", "Do-nothing backend. Probably not useful", "Usage: null://",
func(*url.URL) (backend.Backend, error) {
return &NullBackend{}, nil
})
}

149
backend/redis/redis.go Normal file
View File

@@ -0,0 +1,149 @@
package redis
import (
"context"
"encoding/json"
"fmt"
"git.thequux.com/thequux/ipasso/backend"
"github.com/redis/go-redis/v9"
"net/url"
"time"
)
var redisHelp = `
Usage: redis://<user>:<password>@<host>:<port>/<db_number>
redisu://<user>:<password>@</path/to/redis.sock>?db=<db_number>
rediss://<user>:<password>@<host>:<port>/<db_number>?addr=<host:port>&addr=<host:port>...
The redis scheme connects to a normal Redis server over TCP
the redisu scheme connects to a normal Redis server over a Unix domain socket
the rediss scheme connects to a redis cluster
`
func init() {
backend.RegisterBackendFactory("redis", "Redis TCP", redisHelp, DialRedis)
backend.RegisterBackendFactory("redisu", "Redis Unix", redisHelp, DialRedis)
backend.RegisterBackendFactory("rediss", "Redis Cluster", redisHelp, DialRedis)
}
func DialRedis(url *url.URL) (be backend.Backend, err error) {
var urlString = url.String()
var client redis.UniversalClient
if url.Scheme == "redis" || url.Scheme == "redisu" {
var options *redis.Options
options, err = redis.ParseURL(urlString)
client = redis.NewClient(options)
} else if url.Scheme == "rediss" {
var options *redis.ClusterOptions
options, err = redis.ParseClusterURL(urlString)
client = redis.NewClusterClient(options)
}
if err == nil {
be = &RedisBackend{
rdb: client,
}
}
return
}
func sessionKey(id string) string {
return "session:" + id
}
func scacheKey(id string) string {
return "scache:" + id
}
type RedisBackend struct {
rdb redis.UniversalClient
}
var putSessionScript = redis.NewScript(`
local skey = KEYS[1]
local ckey = KEYS[2]
local session = ARGV[1]
local sexp = ARGV[2]
local cache = ARGV[3]
local clifetime = ARGV[4]
if redis.call("GET", skey) ~= "" then
return false
else
redis.call("SET", skey, session, "EXAT", sexp)
redis.call("SET", ckey, cache, "EX", 30)
return true
end
`)
func (r *RedisBackend) PutSession(ctx context.Context, session backend.Session, cache backend.SessionCache) error {
jsonSession, err := json.Marshal(session)
if err != nil {
return err
}
jsonCache, err := json.Marshal(cache)
if err != nil {
return err
}
result, err := putSessionScript.Run(ctx, r.rdb,
[]string{sessionKey(session.SessionID), scacheKey(session.SessionID)},
jsonSession,
session.Expiration.Unix(),
jsonCache,
30,
).Bool()
if err != nil {
return err
} else if result {
return nil
} else {
return backend.ErrReservationSniped
}
}
func (r *RedisBackend) GetSession(ctx context.Context, id string) (backend.Session, *backend.SessionCache, error) {
var session backend.Session
var cache backend.SessionCache
var cachep = &cache
result, err := r.rdb.MGet(ctx, sessionKey(id), scacheKey(id)).Result()
if err != nil {
return session, nil, err
}
fmt.Printf("Result: %#v\n", result)
v, ok := result[0].(string)
if !ok {
return backend.Session{}, nil, backend.ErrBackendData
}
if err = json.Unmarshal([]byte(v), &session); err != nil {
return backend.Session{}, nil, err
}
v, ok = result[1].(string)
if ok {
if err = json.Unmarshal([]byte(v), &cache); err != nil {
return backend.Session{}, nil, err
}
} else {
cachep = nil
}
return session, cachep, nil
}
func (r *RedisBackend) EndSession(ctx context.Context, id string) {
r.rdb.Del(ctx, sessionKey(id), scacheKey(id))
}
func (r *RedisBackend) ReserveSessionID(ctx context.Context, id string) (bool, error) {
return r.rdb.SetNX(ctx, sessionKey(id), "", time.Second*60).Result()
}
func (r *RedisBackend) DoMaintenance(ctx context.Context) {
// Redis handles cleaning up expired keys itself
}
func (r *RedisBackend) Ping(ctx context.Context) error {
return r.rdb.Ping(ctx).Err()
}

104
backend/registration.go Normal file
View File

@@ -0,0 +1,104 @@
package backend
import (
"flag"
"fmt"
"git.thequux.com/thequux/ipasso/util/startup"
"go.uber.org/zap"
"net/url"
"os"
"sort"
"strings"
)
var backendUrl = flag.String("backend", "help://", "URL of backend. Use help:// for a list, or help://[backend] for details on a particular backend")
var SessionStore Backend
type backendRecord struct {
description string
help string
factory Factory
}
// Factory for backend objects. You don't need this unless you're working on
// initialization machinery or implementing a backend
type Factory func(url *url.URL) (Backend, error)
var sessionLogger *zap.Logger
var registry = make(map[string]backendRecord)
func RegisterBackendFactory(scheme string, description string, help string, factory Factory) {
scheme = strings.ToLower(scheme)
_, exist := registry[scheme]
if exist {
panic("Scheme " + scheme + " registered multiple times")
}
registry[scheme] = backendRecord{
description: description,
help: help,
factory: factory,
}
}
func init() {
// register the help factory
RegisterBackendFactory("help", "Help on backends", `
Usage:
help:// List available backends
help://backend Print help on a specific backend
`,
backendHelp,
)
startup.Logger.Add(func() {
sessionLogger = zap.L().Named("session")
})
startup.PostFlags.Add(func() {
parsed, err := url.Parse(*backendUrl)
if err != nil {
sessionLogger.Fatal("Invalid backend spec", zap.Error(err))
}
backend, ok := registry[parsed.Scheme]
if !ok {
sessionLogger.Fatal("Unknown backend type", zap.String("scheme", parsed.Scheme))
}
SessionStore, err = backend.factory(parsed)
if err != nil {
sessionLogger.Fatal("Failed to initialize backend", zap.Error(err))
}
})
}
func backendHelp(url *url.URL) (Backend, error) {
scheme := url.Hostname()
if scheme != "" {
backend, ok := registry[scheme]
if ok {
fmt.Println(strings.TrimPrefix(backend.help, "\n"))
os.Exit(1)
}
}
var backendList []string
var maxLen = 0
for name := range registry {
backendList = append(backendList, name)
if len(name) > maxLen {
maxLen = len(name)
}
}
sort.Strings(backendList)
fmt.Println("Available backends:")
for _, name := range backendList {
fmt.Printf("%*s %s\n", maxLen, name, registry[name].description)
}
os.Exit(1)
return nil, nil
}

View File

@@ -1,6 +1,7 @@
# Authorization flow
Current authorization state is passwd around via a signed cookie containing authorization details in JSON.
Current authorization state is passed around via an opaque session ID in a cookie.
This cookie is stored in duplicate: once on the SSO domain and once on the protected domain (for use by RPX's)
Most pages do not have any form of authorization, and simply depend on the cookie.
However, in order to log in, more complex flow is necessary.
@@ -26,7 +27,7 @@ The user may also submit the form with blank username/password, in which case au
These steps are performed in sequence, each returning the same data as `/login/status`.
The process completes when the state is `VALID` or `EXPLICIT_LOGOUT`.
1. Fetch `/login/spnego` via XHR GET.
1. Fetch `/login/krb5` via XHR GET.
2. Fetch `/login/x509` via XHR GET.
Upon successful login, a redirect is performed to the page in the hidden field.
@@ -36,23 +37,8 @@ The process completes when the state is `VALID` or `EXPLICIT_LOGOUT`.
In addition to the `/login` endpoint, there exist several other useful endpoints:
* `/logout`: Set cookie to indicate logged-out state
* `/status`: Fetch a representation of user's info.
* `/login/status`: Fetch a representation of user's info.
If `Accept` requests `application/json`, this is JSON.
Otherwise, this is an HTML page
* `/sigkey`: Fetch the current signing key. This is an Ed25519 key.
* `/refresh`: Refresh the user data cookie given the refresh token.
# Two cookies
In the above description, the login token was described as a single cookie.
It is, in fact, two cookies:
* A short-lived (~seconds) user data cookie
* A longer-lived (~8 hour) refresh token, tied to an auth server
If the user data cookie expires, the refresh token can be fed to `/refresh` to update the user data cookie.
The purpose of the user data cookie is solely to reduce the load on the SSO server; the resources fetched by a page will
generally be fetched within a few seconds of the initial request.
It is the reverse proxy's responsibility to refresh the user data cookie and translate it into headers or FCGI environment variables for the backend server.
This task can be assisted by ipasso-rpxagentd (to be specified).
An OAuth interface is also available on **TODO**

13
go.mod
View File

@@ -18,7 +18,11 @@ require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
@@ -26,7 +30,14 @@ require (
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/ory/hydra-client-go/v2 v2.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/redis/go-redis/v9 v9.2.1 // indirect
github.com/stretchr/testify v1.8.4 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/oauth2 v0.7.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.0 // indirect
)

36
go.sum
View File

@@ -6,14 +6,24 @@ github.com/CloudyKit/jet/v6 v6.2.0 h1:EpcZ6SR9n28BUGtNJSvlBqf90IpjeFr36Tizxhn/oM
github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A=
github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
@@ -37,16 +47,22 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/ory/hydra-client-go/v2 v2.1.1 h1:3JatU9uFbw5XhF3lgPCas1l1Kok2v5Mq1p26zZwGHNg=
github.com/ory/hydra-client-go/v2 v2.1.1/go.mod h1:IiIwChp/9wRvPoyFQblqPvg78uVishCCrV9+/M7Pl34=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.2.1 h1:WlYJg71ODF0dVspZZCpYmoF1+U1Jjk9Rwd7pq6QmlCg=
github.com/redis/go-redis/v9 v9.2.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
gitlab.com/jamietanna/content-negotiation-go v0.2.0 h1:vT0OLEPQ6DYRG3/1F7joXSNjVQHGivJ6+JzODlJfjWw=
gitlab.com/jamietanna/content-negotiation-go v0.2.0/go.mod h1:n4ZZ8/X5TstnjYRnjEtR/fC7MCTe+aRKM7PQlLBH3PQ=
@@ -63,6 +79,7 @@ golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -70,8 +87,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -91,6 +111,7 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@@ -102,6 +123,13 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -12,6 +12,7 @@ import (
"net/http"
"os"
"path"
"reflect"
"strings"
)
@@ -58,7 +59,7 @@ func init() {
l.Fatal("Unable to find templates")
}
Templates = loadTemplates(templateFS, *externalResources == "")
Templates = loadTemplates(templateFS, *externalResources != "")
StaticFiles, err = fs.Sub(Resources, "static")
if err != nil {
l.Fatal("Cannot find static files", zap.Error(err))
@@ -98,9 +99,19 @@ func extractResources(dst string) {
})
}
type AvailableTemplates int
const (
ATHtml AvailableTemplates = 1 << iota
ATText
ATComboCount
)
type TemplateSource struct {
htmlCache *jet.Set
textCache *jet.Set
htmlCache *jet.Set
textCache *jet.Set
isDevMode bool
availableContentTypes map[string]AvailableTemplates
}
func loadTemplates(src fs.FS, devMode bool) TemplateSource {
@@ -112,17 +123,63 @@ func loadTemplates(src fs.FS, devMode bool) TemplateSource {
}
loader := genFsLoader{fs: src}
return TemplateSource{
htmlCache: jet.NewSet(loader, devModeOpt),
textCache: jet.NewSet(loader, devModeOpt, jet.WithSafeWriter(nil)),
res := TemplateSource{
isDevMode: devMode,
htmlCache: jet.NewSet(loader, devModeOpt, jet.WithTemplateNameExtensions([]string{".html", ".html.jet"})),
textCache: jet.NewSet(loader, devModeOpt, jet.WithTemplateNameExtensions([]string{".txt", ".txt.jet"}), jet.WithSafeWriter(nil)),
availableContentTypes: make(map[string]AvailableTemplates),
}
res.htmlCache.AddGlobal("TemplateMode", "html")
res.textCache.AddGlobal("TemplateMode", "text")
return res
}
func (ts TemplateSource) HtmlTemplate(name string) (*jet.Template, error) {
// Add a global variable available to all templates
func (ts *TemplateSource) AddGlobalVar(name string, value interface{}) {
ts.textCache.AddGlobal(name, value)
ts.htmlCache.AddGlobal(name, value)
}
// Add a global function available to HTML templates
func (ts *TemplateSource) AddHtmlFunction(name string, value func(arguments jet.Arguments) reflect.Value) {
ts.htmlCache.AddGlobalFunc(name, value)
}
// Adds a global function available to text templates
func (ts *TemplateSource) AddTextFunction(name string, value func(arguments jet.Arguments) reflect.Value) {
ts.htmlCache.AddGlobalFunc(name, value)
}
// Adds a global function available to all templates. Equivalent to calling AddHtmlFunction and AddTextFunction
func (ts *TemplateSource) AddFunction(name string, value func(arguments jet.Arguments) reflect.Value) {
ts.AddHtmlFunction(name, value)
ts.AddTextFunction(name, value)
}
func (ts *TemplateSource) GetContentTypes(name string) AvailableTemplates {
if result, ok := ts.availableContentTypes[name]; ok {
return result
}
var result AvailableTemplates
if _, err := ts.HtmlTemplate(name); err == nil {
result = result | ATHtml
}
if _, err := ts.TextTemplate(name); err == nil {
result = result | ATText
}
if !ts.isDevMode {
ts.availableContentTypes[name] = result
}
return result
}
func (ts *TemplateSource) HtmlTemplate(name string) (*jet.Template, error) {
return ts.htmlCache.GetTemplate(name)
}
func (ts TemplateSource) TextTemplate(name string) (*jet.Template, error) {
func (ts *TemplateSource) TextTemplate(name string) (*jet.Template, error) {
return ts.textCache.GetTemplate(name)
}

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SSO Login</title>
<script src="/login/login.js"></script>
</head>
<body>
<form method="post">
<table>
<tr>
<td><label for="user">User</label></td>
<td><input id="user" name="user" type="text"></td>
</tr>
<tr>
<td><label for="password">Password</label></td>
<td><input id="password" name="password" type="password"></td>
</tr>
<tr>
<td></td>
<td>
<input type="submit" value="Log in" id="doit">
</td>
</tr>
</table>
</form>
</body>
</html>

View File

@@ -0,0 +1,33 @@
// Attempt to log in using ambient authority (e.g., Kerberos, SSL, etc)
async function tryLoginAmbient() {
let resp = await fetch("/login/krb5" + window.location.search, {method: "POST", redirect: "manual"})
if (resp.ok) {
window.location = await resp.text()
}
}
async function tryLoginPassword() {
let userField = document.getElementById("user")
let passwordField = document.getElementById("password")
let doit = document.getElementById("doit")
try {
userField.enabled = passwordField.enabled = doit.enabled = false
let resp = await fetch("/login/password" + window.location.search, {method: "POST"})
if (resp.ok) {
window.location = await resp.text()
}
} catch (e) {
userField.enabled = passwordField.enabled = doit.enabled = true
throw e
}
}
tryLoginAmbient() // don't bother awaiting, just let 'er go
document.addEventListener("load", () => {
document.getElementById("doit").form.addEventListener("submit", function (e) {
e.preventDefault()
tryLoginPassword()
return false
})
})

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Consent request</title>
</head>
<body>
<pre>{{ .ConsentRequest }}</pre>
<hr>
</body>
</html>

View File

@@ -0,0 +1 @@
{{ .ConsentRequest }}

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login status</title>
</head>
<body>
{{-
</body>
</html>

View File

@@ -1,74 +0,0 @@
package backend
import (
"errors"
"go.uber.org/zap"
"time"
)
type UserType string
var (
UtPerson UserType = "person"
UtHost UserType = "host"
UtService UserType = "service"
)
// Session holds info about the logged-in user that won't change
type Session struct {
SessionID string
Expiration time.Time
UserID string
UserType UserType
LdapDN string
}
// SessionCache holds volatile information about the logged-in user
type SessionCache struct {
Valid bool
Groups []string
DisplayName string
GivenName string
SurName string
Email string
}
type Backend interface {
PutSession(session Session, cache SessionCache) error
GetSession(id string) (Session, *SessionCache, error)
EndSession(id string)
NewSessionID() (string, error)
DoMaintenance()
Ping() error
}
type NullBackend struct{}
func (n *NullBackend) PutSession(session Session, cache SessionCache) error {
//TODO implement me
zap.L().Info("Put session", zap.Any("session", session), zap.Any("cache", cache))
return nil
}
func (n *NullBackend) GetSession(id string) (Session, *SessionCache, error) {
//TODO implement me
return Session{}, nil, errors.New("no such session")
}
func (n *NullBackend) EndSession(id string) {
//TODO implement me
}
func (n *NullBackend) NewSessionID() (string, error) {
//TODO implement me
return "foo", nil
}
func (n *NullBackend) DoMaintenance() {
//TODO implement me
}
func (n *NullBackend) Ping() error {
//TODO implement me
return nil
}

View File

@@ -4,11 +4,14 @@ import "github.com/julienschmidt/httprouter"
// Pre-defined queues...
var (
// Configure loggers
Logger Phase
// Phase
// Post-process flags, applying computed defaults.
PostFlags Phase
Startup Phase
Routes ParameterizedPhase[*httprouter.Router]
// Make external connections
Startup Phase
// Gather HTTP routes
Routes ParameterizedPhase[*httprouter.Router]
)
type Phase struct {