mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-10-27 20:25:41 +00:00
422 lines
10 KiB
Go
422 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"tinyauth/internal/config"
|
|
"tinyauth/internal/model"
|
|
"tinyauth/internal/utils"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type LoginAttempt struct {
|
|
FailedAttempts int
|
|
LastAttempt time.Time
|
|
LockedUntil time.Time
|
|
}
|
|
|
|
type AuthServiceConfig struct {
|
|
Users []config.User
|
|
OauthWhitelist string
|
|
SessionExpiry int
|
|
SecureCookie bool
|
|
CookieDomain string
|
|
LoginTimeout int
|
|
LoginMaxRetries int
|
|
SessionCookieName string
|
|
}
|
|
|
|
type AuthService struct {
|
|
config AuthServiceConfig
|
|
docker *DockerService
|
|
loginAttempts map[string]*LoginAttempt
|
|
loginMutex sync.RWMutex
|
|
ldap *LdapService
|
|
database *gorm.DB
|
|
}
|
|
|
|
func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, database *gorm.DB) *AuthService {
|
|
return &AuthService{
|
|
config: config,
|
|
docker: docker,
|
|
loginAttempts: make(map[string]*LoginAttempt),
|
|
ldap: ldap,
|
|
database: database,
|
|
}
|
|
}
|
|
|
|
func (auth *AuthService) Init() error {
|
|
return nil
|
|
}
|
|
|
|
func (auth *AuthService) SearchUser(username string) config.UserSearch {
|
|
if auth.GetLocalUser(username).Username != "" {
|
|
return config.UserSearch{
|
|
Username: username,
|
|
Type: "local",
|
|
}
|
|
}
|
|
|
|
if auth.ldap != nil {
|
|
userDN, err := auth.ldap.Search(username)
|
|
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("username", username).Msg("Failed to search for user in LDAP")
|
|
return config.UserSearch{
|
|
Type: "error",
|
|
}
|
|
}
|
|
|
|
return config.UserSearch{
|
|
Username: userDN,
|
|
Type: "ldap",
|
|
}
|
|
}
|
|
|
|
return config.UserSearch{
|
|
Type: "unknown",
|
|
}
|
|
}
|
|
|
|
func (auth *AuthService) VerifyUser(search config.UserSearch, password string) bool {
|
|
switch search.Type {
|
|
case "local":
|
|
user := auth.GetLocalUser(search.Username)
|
|
return auth.CheckPassword(user, password)
|
|
case "ldap":
|
|
if auth.ldap != nil {
|
|
err := auth.ldap.Bind(search.Username, password)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("username", search.Username).Msg("Failed to bind to LDAP")
|
|
return false
|
|
}
|
|
|
|
err = auth.ldap.Bind(auth.ldap.Config.BindDN, auth.ldap.Config.BindPassword)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to rebind with service account after user authentication")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
default:
|
|
log.Debug().Str("type", search.Type).Msg("Unknown user type for authentication")
|
|
return false
|
|
}
|
|
|
|
log.Warn().Str("username", search.Username).Msg("User authentication failed")
|
|
return false
|
|
}
|
|
|
|
func (auth *AuthService) GetLocalUser(username string) config.User {
|
|
for _, user := range auth.config.Users {
|
|
if user.Username == username {
|
|
return user
|
|
}
|
|
}
|
|
|
|
log.Warn().Str("username", username).Msg("Local user not found")
|
|
return config.User{}
|
|
}
|
|
|
|
func (auth *AuthService) CheckPassword(user config.User, password string) bool {
|
|
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
|
|
}
|
|
|
|
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
|
auth.loginMutex.RLock()
|
|
defer auth.loginMutex.RUnlock()
|
|
|
|
if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 {
|
|
return false, 0
|
|
}
|
|
|
|
attempt, exists := auth.loginAttempts[identifier]
|
|
if !exists {
|
|
return false, 0
|
|
}
|
|
|
|
if attempt.LockedUntil.After(time.Now()) {
|
|
remaining := int(time.Until(attempt.LockedUntil).Seconds())
|
|
return true, remaining
|
|
}
|
|
|
|
return false, 0
|
|
}
|
|
|
|
func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
|
if auth.config.LoginMaxRetries <= 0 || auth.config.LoginTimeout <= 0 {
|
|
return
|
|
}
|
|
|
|
auth.loginMutex.Lock()
|
|
defer auth.loginMutex.Unlock()
|
|
|
|
attempt, exists := auth.loginAttempts[identifier]
|
|
if !exists {
|
|
attempt = &LoginAttempt{}
|
|
auth.loginAttempts[identifier] = attempt
|
|
}
|
|
|
|
attempt.LastAttempt = time.Now()
|
|
|
|
if success {
|
|
attempt.FailedAttempts = 0
|
|
attempt.LockedUntil = time.Time{} // Reset lock time
|
|
return
|
|
}
|
|
|
|
attempt.FailedAttempts++
|
|
|
|
if attempt.FailedAttempts >= auth.config.LoginMaxRetries {
|
|
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second)
|
|
log.Warn().Str("identifier", identifier).Int("timeout", auth.config.LoginTimeout).Msg("Account locked due to too many failed login attempts")
|
|
}
|
|
}
|
|
|
|
func (auth *AuthService) IsEmailWhitelisted(email string) bool {
|
|
return utils.CheckFilter(auth.config.OauthWhitelist, email)
|
|
}
|
|
|
|
func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.SessionCookie) error {
|
|
uuid, err := uuid.NewRandom()
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var expiry int
|
|
|
|
if data.TotpPending {
|
|
expiry = 3600
|
|
} else {
|
|
expiry = auth.config.SessionExpiry
|
|
}
|
|
|
|
session := model.Session{
|
|
UUID: uuid.String(),
|
|
Username: data.Username,
|
|
Email: data.Email,
|
|
Name: data.Name,
|
|
Provider: data.Provider,
|
|
TOTPPending: data.TotpPending,
|
|
OAuthGroups: data.OAuthGroups,
|
|
Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(),
|
|
OAuthName: data.OAuthName,
|
|
}
|
|
|
|
err = auth.database.Create(&session).Error
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c.SetCookie(auth.config.SessionCookieName, session.UUID, expiry, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (auth *AuthService) DeleteSessionCookie(c *gin.Context) error {
|
|
cookie, err := c.Cookie(auth.config.SessionCookieName)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
res := auth.database.Unscoped().Where("uuid = ?", cookie).Delete(&model.Session{})
|
|
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
|
|
c.SetCookie(auth.config.SessionCookieName, "", -1, "/", fmt.Sprintf(".%s", auth.config.CookieDomain), auth.config.SecureCookie, true)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, error) {
|
|
cookie, err := c.Cookie(auth.config.SessionCookieName)
|
|
|
|
if err != nil {
|
|
return config.SessionCookie{}, err
|
|
}
|
|
|
|
var session model.Session
|
|
|
|
res := auth.database.Unscoped().Where("uuid = ?", cookie).First(&session)
|
|
|
|
if res.Error != nil {
|
|
return config.SessionCookie{}, res.Error
|
|
}
|
|
|
|
if res.RowsAffected == 0 {
|
|
return config.SessionCookie{}, fmt.Errorf("session not found")
|
|
}
|
|
|
|
currentTime := time.Now().Unix()
|
|
|
|
if currentTime > session.Expiry {
|
|
res := auth.database.Unscoped().Where("uuid = ?", session.UUID).Delete(&model.Session{})
|
|
if res.Error != nil {
|
|
log.Error().Err(res.Error).Msg("Failed to delete expired session")
|
|
}
|
|
return config.SessionCookie{}, fmt.Errorf("session expired")
|
|
}
|
|
|
|
return config.SessionCookie{
|
|
UUID: session.UUID,
|
|
Username: session.Username,
|
|
Email: session.Email,
|
|
Name: session.Name,
|
|
Provider: session.Provider,
|
|
TotpPending: session.TOTPPending,
|
|
OAuthGroups: session.OAuthGroups,
|
|
OAuthName: session.OAuthName,
|
|
}, nil
|
|
}
|
|
|
|
func (auth *AuthService) UserAuthConfigured() bool {
|
|
return len(auth.config.Users) > 0 || auth.ldap != nil
|
|
}
|
|
|
|
func (auth *AuthService) IsResourceAllowed(c *gin.Context, context config.UserContext, labels config.App) bool {
|
|
if context.OAuth {
|
|
log.Debug().Msg("Checking OAuth whitelist")
|
|
return utils.CheckFilter(labels.OAuth.Whitelist, context.Email)
|
|
}
|
|
|
|
if labels.Users.Block != "" {
|
|
log.Debug().Msg("Checking blocked users")
|
|
if utils.CheckFilter(labels.Users.Block, context.Username) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
log.Debug().Msg("Checking users")
|
|
return utils.CheckFilter(labels.Users.Allow, context.Username)
|
|
}
|
|
|
|
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context config.UserContext, requiredGroups string) bool {
|
|
if requiredGroups == "" {
|
|
return true
|
|
}
|
|
|
|
for id := range config.OverrideProviders {
|
|
if context.Provider == id {
|
|
log.Info().Str("provider", id).Msg("OAuth groups not supported for this provider")
|
|
return true
|
|
}
|
|
}
|
|
|
|
for userGroup := range strings.SplitSeq(context.OAuthGroups, ",") {
|
|
if utils.CheckFilter(requiredGroups, strings.TrimSpace(userGroup)) {
|
|
log.Trace().Str("group", userGroup).Str("required", requiredGroups).Msg("User group matched")
|
|
return true
|
|
}
|
|
}
|
|
|
|
log.Debug().Msg("No groups matched")
|
|
return false
|
|
}
|
|
|
|
func (auth *AuthService) IsAuthEnabled(uri string, path config.AppPath) (bool, error) {
|
|
// Check for block list
|
|
if path.Block != "" {
|
|
regex, err := regexp.Compile(path.Block)
|
|
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
|
|
if !regex.MatchString(uri) {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// Check for allow list
|
|
if path.Allow != "" {
|
|
regex, err := regexp.Compile(path.Allow)
|
|
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
|
|
if regex.MatchString(uri) {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (auth *AuthService) GetBasicAuth(c *gin.Context) *config.User {
|
|
username, password, ok := c.Request.BasicAuth()
|
|
if !ok {
|
|
log.Debug().Msg("No basic auth provided")
|
|
return nil
|
|
}
|
|
return &config.User{
|
|
Username: username,
|
|
Password: password,
|
|
}
|
|
}
|
|
|
|
func (auth *AuthService) CheckIP(labels config.AppIP, ip string) bool {
|
|
for _, blocked := range labels.Block {
|
|
res, err := utils.FilterIP(blocked, ip)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
|
|
continue
|
|
}
|
|
if res {
|
|
log.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access")
|
|
return false
|
|
}
|
|
}
|
|
|
|
for _, allowed := range labels.Allow {
|
|
res, err := utils.FilterIP(allowed, ip)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
|
|
continue
|
|
}
|
|
if res {
|
|
log.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access")
|
|
return true
|
|
}
|
|
}
|
|
|
|
if len(labels.Allow) > 0 {
|
|
log.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
|
|
return false
|
|
}
|
|
|
|
log.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default")
|
|
return true
|
|
}
|
|
|
|
func (auth *AuthService) IsBypassedIP(labels config.AppIP, ip string) bool {
|
|
for _, bypassed := range labels.Bypass {
|
|
res, err := utils.FilterIP(bypassed, ip)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
|
continue
|
|
}
|
|
if res {
|
|
log.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access")
|
|
return true
|
|
}
|
|
}
|
|
|
|
log.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication")
|
|
return false
|
|
}
|