mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-10-28 04:35:40 +00:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user