// Copyright (C) NHR@FAU, University Erlangen-Nuremberg. // All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package auth import ( "context" "crypto/rand" "database/sql" "encoding/base64" "errors" "log/slog" "net" "net/http" "os" "sync" "time" "git.clustercockpit.org/moebiusband/go-http-skeleton/internal/repository" "github.com/gorilla/sessions" "golang.org/x/time/rate" ) type Authenticator interface { CanLogin(user *repository.AppUser, username string, rw http.ResponseWriter, r *http.Request) (*repository.AppUser, bool) Login(user *repository.AppUser, rw http.ResponseWriter, r *http.Request) (*repository.AppUser, error) } var ( initOnce sync.Once authInstance *Authentication ) var ipUserLimiters sync.Map func getIPUserLimiter(ip, username string) *rate.Limiter { key := ip + ":" + username limiter, ok := ipUserLimiters.Load(key) if !ok { newLimiter := rate.NewLimiter(rate.Every(time.Hour/10), 10) ipUserLimiters.Store(key, newLimiter) return newLimiter } return limiter.(*rate.Limiter) } type Authentication struct { sessionStore *sessions.CookieStore LocalAuth *LocalAuthenticator authenticators []Authenticator SessionMaxAge time.Duration } func (auth *Authentication) AuthViaSession( rw http.ResponseWriter, r *http.Request, ) (*repository.AppUser, error) { session, err := auth.sessionStore.Get(r, "session") if err != nil { slog.Error("Error while getting session store") return nil, err } if session.IsNew { return nil, nil } // TODO: Check if session keys exist username, _ := session.Values["username"].(string) return &repository.AppUser{ UserName: username, }, nil } func Init() { initOnce.Do(func() { authInstance = &Authentication{} sessKey := os.Getenv("SESSION_KEY") if sessKey == "" { slog.Warn("environment variable 'SESSION_KEY' not set (will use non-persistent random key)") bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { slog.Error("Error while initializing authentication -> failed to generate random bytes for session key") os.Exit(1) } authInstance.sessionStore = sessions.NewCookieStore(bytes) } else { bytes, err := base64.StdEncoding.DecodeString(sessKey) if err != nil { slog.Error("Error while initializing authentication -> decoding session key failed") os.Exit(1) } authInstance.sessionStore = sessions.NewCookieStore(bytes) } if d, err := time.ParseDuration("24h"); err == nil { authInstance.SessionMaxAge = d } authInstance.LocalAuth = &LocalAuthenticator{} if err := authInstance.LocalAuth.Init(); err != nil { slog.Error("Error while initializing authentication -> localAuth init failed") os.Exit(1) } authInstance.authenticators = append(authInstance.authenticators, authInstance.LocalAuth) }) } func GetAuthInstance() *Authentication { if authInstance == nil { slog.Error("Authentication module not initialized!") } return authInstance } func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *repository.AppUser, ) error { session, err := auth.sessionStore.New(r, "session") if err != nil { slog.Error("session creation failed", "error", err.Error()) http.Error(rw, err.Error(), http.StatusInternalServerError) return err } if auth.SessionMaxAge != 0 { session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) } session.Options.Secure = false session.Options.SameSite = http.SameSiteStrictMode session.Values["username"] = user.UserName if err := auth.sessionStore.Save(r, rw, session); err != nil { slog.Warn("session save failed", "error", err.Error()) http.Error(rw, err.Error(), http.StatusInternalServerError) return err } return nil } func (auth *Authentication) Login( onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error), ) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { ip = r.RemoteAddr } username := r.FormValue("username") limiter := getIPUserLimiter(ip, username) if !limiter.Allow() { slog.Warn("AUTH/RATE > Too many login attempts for combination", "ip", ip, "username", username) onfailure(rw, r, errors.New("too many login attempts, try again in a few minutes")) return } var dbUser repository.AppUser if username != "" { var err error dbUser, err = repository.GetRepository().GetUser(r.Context(), username) if err != nil && err != sql.ErrNoRows { slog.Error("Error while loading user", "username", username) } } for _, authenticator := range auth.authenticators { var ok bool var user *repository.AppUser if user, ok = authenticator.CanLogin(&dbUser, username, rw, r); !ok { continue } else { slog.Debug("Can login with user", "username", user.UserName) } user, err := authenticator.Login(user, rw, r) if err != nil { slog.Warn("user login failed", "error", err.Error()) onfailure(rw, r, err) return } if err := auth.SaveSession(rw, r, user); err != nil { return } slog.Info("login successfull", "user", user.UserName) ctx := context.WithValue(r.Context(), "user", user) if r.FormValue("redirect") != "" { http.RedirectHandler(r.FormValue("redirect"), http.StatusFound).ServeHTTP(rw, r.WithContext(ctx)) return } http.RedirectHandler("/", http.StatusFound).ServeHTTP(rw, r.WithContext(ctx)) return } slog.Debug("login failed: no authenticator applied") onfailure(rw, r, errors.New("no authenticator applied")) }) } func (auth *Authentication) Auth( onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, authErr error), ) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { user, err := auth.AuthViaSession(rw, r) if err != nil { slog.Info("auth -> authentication failed", "error", err.Error()) http.Error(rw, err.Error(), http.StatusUnauthorized) return } if user != nil { ctx := context.WithValue(r.Context(), "user", user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) return } slog.Info("auth -> authentication failed") onfailure(rw, r, errors.New("unauthorized (please login first)")) }) } func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { session, err := auth.sessionStore.Get(r, "session") if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } if !session.IsNew { session.Options.MaxAge = -1 if err := auth.sessionStore.Save(r, rw, session); err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } } onsuccess.ServeHTTP(rw, r) }) }