Feat/totp (#45)

* wip

* feat: finalize totp gen code

* refactor: split login screen and forms

* feat: add totp logic and ui

* refactor: make totp pending expiry time fixed

* refactor: skip all checks when disable continue is enabled

* fix: fix cli not exiting on invalid input
This commit is contained in:
Stavros
2025-03-09 18:39:25 +02:00
committed by GitHub
parent 47fff12bac
commit 5188089673
24 changed files with 862 additions and 270 deletions

View File

@@ -19,6 +19,7 @@ import (
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog/log"
)
@@ -321,7 +322,29 @@ func (api *API) SetupRoutes() {
return
}
log.Debug().Msg("Password correct, logging in")
log.Debug().Msg("Password correct, checking totp")
// Check if user has totp enabled
if user.TotpSecret != "" {
log.Debug().Msg("Totp enabled")
// Set totp pending cookie
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: login.Username,
Provider: "username",
TotpPending: true,
})
// Return totp required
c.JSON(200, gin.H{
"status": 200,
"message": "Waiting for totp",
"totpPending": true,
})
// Stop further processing
return
}
// Create session cookie with username as provider
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
@@ -329,6 +352,80 @@ func (api *API) SetupRoutes() {
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
"totpPending": false,
})
})
api.Router.POST("/api/totp", func(c *gin.Context) {
// Create totp struct
var totpReq types.Totp
// Bind JSON
err := c.BindJSON(&totpReq)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to bind JSON")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
log.Debug().Msg("Checking totp")
// Get user context
userContext := api.Hooks.UseUserContext(c)
// Check if we have a user
if userContext.Username == "" {
log.Debug().Msg("No user context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Get user
user := api.Auth.GetUser(userContext.Username)
// Check if user exists
if user == nil {
log.Debug().Msg("User not found")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
// Check if totp is correct
totpOk := totp.Validate(totpReq.Code, user.TotpSecret)
// TOTP is incorrect
if !totpOk {
log.Debug().Msg("Totp incorrect")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
log.Debug().Msg("Totp correct")
// Create session cookie with username as provider
api.Auth.CreateSessionCookie(c, &types.SessionCookie{
Username: user.Username,
Provider: "username",
})
// Return logged in
c.JSON(200, gin.H{
"status": 200,
@@ -378,6 +475,7 @@ func (api *API) SetupRoutes() {
DisableContinue: api.Config.DisableContinue,
Title: api.Config.Title,
GenericName: api.Config.GenericName,
TotpPending: userContext.TotpPending,
}
// If we are not logged in we set the status to 401 and add the WWW-Authenticate header else we set it to 200
@@ -392,19 +490,6 @@ func (api *API) SetupRoutes() {
status.Message = "Authenticated"
}
// // Marshall status to JSON
// statusJson, marshalErr := json.Marshal(status)
// // Handle error
// if marshalErr != nil {
// log.Error().Err(marshalErr).Msg("Failed to marshal status")
// c.JSON(500, gin.H{
// "status": 500,
// "message": "Internal Server Error",
// })
// return
// }
// Return data
c.JSON(200, status)
})

View File

@@ -70,10 +70,20 @@ func (auth *Auth) CreateSessionCookie(c *gin.Context, data *types.SessionCookie)
log.Debug().Msg("Setting session cookie")
// Calculate expiry
var sessionExpiry int
if data.TotpPending {
sessionExpiry = 3600
} else {
sessionExpiry = auth.SessionExpiry
}
// Set data
sessions.Set("username", data.Username)
sessions.Set("provider", data.Provider)
sessions.Set("expiry", time.Now().Add(time.Duration(auth.SessionExpiry)*time.Second).Unix())
sessions.Set("expiry", time.Now().Add(time.Duration(sessionExpiry)*time.Second).Unix())
sessions.Set("totpPending", data.TotpPending)
// Save session
sessions.Save()
@@ -102,14 +112,16 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
cookieUsername := sessions.Get("username")
cookieProvider := sessions.Get("provider")
cookieExpiry := sessions.Get("expiry")
cookieTotpPending := sessions.Get("totpPending")
// Convert interfaces to correct types
username, usernameOk := cookieUsername.(string)
provider, providerOk := cookieProvider.(string)
expiry, expiryOk := cookieExpiry.(int64)
totpPending, totpPendingOk := cookieTotpPending.(bool)
// Check if the cookie is invalid
if !usernameOk || !providerOk || !expiryOk {
if !usernameOk || !providerOk || !expiryOk || !totpPendingOk {
log.Warn().Msg("Session cookie invalid")
return types.SessionCookie{}
}
@@ -125,12 +137,13 @@ func (auth *Auth) GetSessionCookie(c *gin.Context) types.SessionCookie {
return types.SessionCookie{}
}
log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Msg("Parsed cookie")
log.Debug().Str("username", username).Str("provider", provider).Int64("expiry", expiry).Bool("totpPending", totpPending).Msg("Parsed cookie")
// Return the cookie
return types.SessionCookie{
Username: username,
Provider: provider,
Username: username,
Provider: provider,
TotpPending: totpPending,
}
}

View File

@@ -36,15 +36,29 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
if user != nil && hooks.Auth.CheckPassword(*user, basic.Password) {
// Return user context since we are logged in with basic auth
return types.UserContext{
Username: basic.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "basic",
Username: basic.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "basic",
TotpPending: false,
}
}
}
// Check if session cookie has totp pending
if cookie.TotpPending {
log.Debug().Msg("Totp pending")
// Return empty context since we are pending totp
return types.UserContext{
Username: cookie.Username,
IsLoggedIn: false,
OAuth: false,
Provider: cookie.Provider,
TotpPending: true,
}
}
// Check if session cookie is username/password auth
if cookie.Provider == "username" {
log.Debug().Msg("Provider is username")
@@ -55,10 +69,11 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// It exists so we are logged in
return types.UserContext{
Username: cookie.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "username",
Username: cookie.Username,
IsLoggedIn: true,
OAuth: false,
Provider: "username",
TotpPending: false,
}
}
}
@@ -81,10 +96,11 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// Return empty context
return types.UserContext{
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
TotpPending: false,
}
}
@@ -92,18 +108,20 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
// Return user context since we are logged in with oauth
return types.UserContext{
Username: cookie.Username,
IsLoggedIn: true,
OAuth: true,
Provider: cookie.Provider,
Username: cookie.Username,
IsLoggedIn: true,
OAuth: true,
Provider: cookie.Provider,
TotpPending: false,
}
}
// Neither basic auth or oauth is set so we return an empty context
return types.UserContext{
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
Username: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
TotpPending: false,
}
}

View File

@@ -15,8 +15,9 @@ type LoginRequest struct {
// User is the struct for a user
type User struct {
Username string
Password string
Username string
Password string
TotpSecret string
}
// Users is a list of users
@@ -58,10 +59,11 @@ type Config struct {
// UserContext is the context for the user
type UserContext struct {
Username string
IsLoggedIn bool
OAuth bool
Provider string
Username string
IsLoggedIn bool
OAuth bool
Provider string
TotpPending bool
}
// APIConfig is the configuration for the API
@@ -114,8 +116,9 @@ type UnauthorizedQuery struct {
// SessionCookie is the cookie for the session (exculding the expiry)
type SessionCookie struct {
Username string
Provider string
Username string
Provider string
TotpPending bool
}
// TinyauthLabels is the labels for the tinyauth container
@@ -147,4 +150,10 @@ type Status struct {
DisableContinue bool `json:"disableContinue"`
Title string `json:"title"`
GenericName string `json:"genericName"`
TotpPending bool `json:"totpPending"`
}
// Totp request
type Totp struct {
Code string `json:"code"`
}

View File

@@ -29,19 +29,15 @@ func ParseUsers(users string) (types.Users, error) {
// Loop through the users and split them by colon
for _, user := range userList {
// Split the user by colon
userSplit := strings.Split(user, ":")
parsed, parseErr := ParseUser(user)
// Check if the user is in the correct format
if len(userSplit) != 2 {
return types.Users{}, errors.New("invalid user format")
// Check if there was an error
if parseErr != nil {
return types.Users{}, parseErr
}
// Append the user to the users struct
usersParsed = append(usersParsed, types.User{
Username: userSplit[0],
Password: userSplit[1],
})
usersParsed = append(usersParsed, parsed)
}
log.Debug().Msg("Parsed users")
@@ -219,3 +215,43 @@ func Filter[T any](slice []T, test func(T) bool) (res []T) {
}
return res
}
// Parse user
func ParseUser(user string) (types.User, error) {
// Check if the user is escaped
if strings.Contains(user, "$$") {
user = strings.ReplaceAll(user, "$$", "$")
}
// Split the user by colon
userSplit := strings.Split(user, ":")
// Check if the user is in the correct format
if len(userSplit) < 2 || len(userSplit) > 3 {
return types.User{}, errors.New("invalid user format")
}
// Check if the user has a totp secret
if len(userSplit) == 2 {
// Check for empty username or password
if userSplit[1] == "" || userSplit[0] == "" {
return types.User{}, errors.New("invalid user format")
}
return types.User{
Username: userSplit[0],
Password: userSplit[1],
}, nil
}
// Check for empty username, password or totp secret
if userSplit[2] == "" || userSplit[1] == "" || userSplit[0] == "" {
return types.User{}, errors.New("invalid user format")
}
// Return the user struct
return types.User{
Username: userSplit[0],
Password: userSplit[1],
TotpSecret: userSplit[2],
}, nil
}

View File

@@ -36,18 +36,6 @@ func TestParseUsers(t *testing.T) {
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing parse users with an invalid string")
// Test the parse users function with an invalid string
users = "user1:pass1,user2"
_, err = utils.ParseUsers(users)
// There should be an error
if err == nil {
t.Fatalf("Expected error parsing users")
}
}
// Test the get root url function
@@ -334,3 +322,65 @@ func TestFilter(t *testing.T) {
t.Fatalf("Expected %v, got %v", expected, result)
}
}
// Test parse user
func TestParseUser(t *testing.T) {
t.Log("Testing parse user with a valid user")
// Create variables
user := "user:pass:secret"
expected := types.User{
Username: "user",
Password: "pass",
TotpSecret: "secret",
}
// Test the parse user function
result, err := utils.ParseUser(user)
// Check if there was an error
if err != nil {
t.Fatalf("Error parsing user: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing parse user with an escaped user")
// Create variables
user = "user:p$$ass$$:secret"
expected = types.User{
Username: "user",
Password: "p$ass$",
TotpSecret: "secret",
}
// Test the parse user function
result, err = utils.ParseUser(user)
// Check if there was an error
if err != nil {
t.Fatalf("Error parsing user: %v", err)
}
// Check if the result is equal to the expected
if !reflect.DeepEqual(expected, result) {
t.Fatalf("Expected %v, got %v", expected, result)
}
t.Log("Testing parse user with an invalid user")
// Create variables
user = "user::pass"
// Test the parse user function
_, err = utils.ParseUser(user)
// Check if there was an error
if err == nil {
t.Fatalf("Expected error parsing user")
}
}