diff --git a/backend/admins.go b/backend/admins.go index 8662df1..1f29056 100644 --- a/backend/admins.go +++ b/backend/admins.go @@ -1,26 +1,14 @@ package backend import ( - "crypto/rand" - "encoding/base64" - "errors" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" - "time" ) type Admin struct { - ID uint64 `gorm:"primaryKey"` - Username string `gorm:"unique;notNull"` - Bcrypt string `gorm:"notNull"` - AccessTokens []AccessToken -} - -type AccessToken struct { - ID uint64 `gorm:"primaryKey"` - Value string `gorm:"notNull"` - AdminID uint64 `gorm:"notNull"` - ValidUntil time.Time `gorm:"NotNull"` + ID uint64 `gorm:"primaryKey"` + Username string `gorm:"unique;notNull"` + Bcrypt string `gorm:"notNull"` } func CountAdmins(db *gorm.DB) int64 { @@ -64,39 +52,3 @@ func AuthorizeAdmin(db *gorm.DB, username string, password string) (Admin, error return admin, nil } - -func CreateAccessToken(db *gorm.DB, adminID uint64) (AccessToken, error) { - bytes := make([]byte, 64) - if _, err := rand.Read(bytes); err != nil { - return AccessToken{}, err - } - - accessToken := AccessToken{ - AdminID: adminID, - Value: base64.StdEncoding.EncodeToString(bytes), - // Valid for 1 month - ValidUntil: time.Now().AddDate(0, 1, 0), - } - result := db.Create(&accessToken) - - if result.Error != nil { - return AccessToken{}, result.Error - } - - return accessToken, nil -} - -func ValidateToken(db *gorm.DB, value string) error { - var token AccessToken - result := db.Where("value = ?", value).First(&token) - - if result.Error != nil { - return result.Error - } - - if time.Now().After(token.ValidUntil) { - return errors.New("Access token expired") - } - - return nil -} diff --git a/backend/db.go b/backend/db.go index 6e11925..31870e6 100644 --- a/backend/db.go +++ b/backend/db.go @@ -13,7 +13,7 @@ func GetDatabaseConnection(cfg *config.Config) (*gorm.DB, error) { } // Migrate the schema - db.AutoMigrate(&Admin{}, &AccessToken{}, &Group{}, &Link{}) + db.AutoMigrate(&Admin{}, &Group{}, &Link{}) return db, nil } diff --git a/go.mod b/go.mod index ae470ee..0c43aef 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/joho/godotenv v1.5.1 // indirect diff --git a/go.sum b/go.sum index 3917e5f..6ab57a8 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= diff --git a/main.go b/main.go index 0be5356..5a335ba 100644 --- a/main.go +++ b/main.go @@ -35,55 +35,63 @@ func main() { r.LoadHTMLGlob("templates/*") r.Static("/assets", "./assets") + r.GET("/signin", func(c *gin.Context) { + views.ShowLoginForm(c) + }) + r.POST("/signin", func(c *gin.Context) { + views.AuthorizeUser(c, db, cfg) + }) + + protected := r.Group("/") + protected.Use(func(c *gin.Context) { + views.AuthMiddleware(c, cfg) + }) + // Main page - r.GET("/", func(c *gin.Context) { + protected.GET("/", func(c *gin.Context) { views.ShowMainPage(c, db) }) - r.GET("/settings", func(c *gin.Context) { + protected.GET("/settings", func(c *gin.Context) { views.ShowSettings(c, db) }) // Create new user - r.POST("/users", func(c *gin.Context) { - views.CreateUser(c, db) - }) - - r.POST("/signin", func(c *gin.Context) { - views.AuthorizeUser(c, db) + protected.POST("/users", func(c *gin.Context) { + views.CreateUser(c, db, cfg) }) // Create new group - r.POST("/groups", func(c *gin.Context) { + protected.POST("/groups", func(c *gin.Context) { views.CreateGroup(c, db) }) // Update group // HTML forms cannot be submitted using PUT or PATCH methods without javascript. - r.POST("/groups/:id/put", func(c *gin.Context) { + protected.POST("/groups/:id/put", func(c *gin.Context) { views.UpdateGroup(c, db) }) // Delete group // HTML forms cannot be submitted using the DELETE method without javascript. - r.POST("/groups/:id/delete", func(c *gin.Context) { + protected.POST("/groups/:id/delete", func(c *gin.Context) { views.DeleteGroup(c, db) }) // Create new link - r.POST("/links", func(c *gin.Context) { + protected.POST("/links", func(c *gin.Context) { views.CreateLink(c, db) }) // Update link. // HTML forms cannot be submitted using PUT or PATCH methods without javascript. - r.POST("/links/:id/put", func(c *gin.Context) { + protected.POST("/links/:id/put", func(c *gin.Context) { views.UpdateLink(c, db) }) // Delete link // HTML forms cannot be submitted using the DELETE method without javascript. - r.POST("/links/:id/delete", func(c *gin.Context) { + protected.POST("/links/:id/delete", func(c *gin.Context) { views.DeleteLink(c, db) }) diff --git a/views/auth.go b/views/auth.go index 0e1f045..5019447 100644 --- a/views/auth.go +++ b/views/auth.go @@ -2,10 +2,14 @@ package views import ( "errors" + "fmt" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" "github.com/ordinary-dev/phoenix/backend" + "github.com/ordinary-dev/phoenix/config" "gorm.io/gorm" "net/http" + "time" ) func ShowRegistrationForm(c *gin.Context) { @@ -27,30 +31,108 @@ func ShowLoginForm(c *gin.Context) { } // Requires the user to log in before viewing the page. -// In case of an error, it shows the login page or the error page. // Returns error if the user is not authorized. // If `nil` is returned instead of an error, it is safe to display protected content. -func RequireAuth(c *gin.Context, db *gorm.DB) error { - number_of_accounts := backend.CountAdmins(db) - - // First run - if number_of_accounts == 0 { - ShowRegistrationForm(c) - } - +func RequireAuth(c *gin.Context, cfg *config.Config) (*jwt.RegisteredClaims, error) { tokenValue, err := c.Cookie("phoenix-token") // Anonymous visitor if err != nil { - ShowLoginForm(c) - return errors.New("User is not authorized") + return nil, err } - err = backend.ValidateToken(db, tokenValue) + // Check token + token, err := jwt.ParseWithClaims(tokenValue, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { + // Validate the alg + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + + return []byte(cfg.SecretKey), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*jwt.RegisteredClaims) + if !ok || !token.Valid { + return nil, errors.New("Token is invalid") + } + + return claims, nil +} + +func AuthMiddleware(c *gin.Context, cfg *config.Config) { + claims, err := RequireAuth(c, cfg) + if err != nil { + c.Redirect(http.StatusFound, "/signin") + c.Abort() + return + } + + // Create a new token if the old one is about to expire + if time.Now().Add(12 * time.Hour).After(claims.ExpiresAt.Time) { + newToken, err := GetJWTToken(cfg) + if err != nil { + ShowError(c, err) + return + } + SetTokenCookie(c, newToken) + } +} + +func GetJWTToken(cfg *config.Config) (string, error) { + claims := jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(cfg.SecretKey)) +} + +func CreateUser(c *gin.Context, db *gorm.DB, cfg *config.Config) { + // Try to create a user. + username := c.PostForm("username") + password := c.PostForm("password") + _, err := backend.CreateAdmin(db, username, password) if err != nil { ShowError(c, err) - return errors.New("Access token is invalid") + return } - return nil + // Generate access token. + token, err := GetJWTToken(cfg) + if err != nil { + ShowError(c, err) + return + } + SetTokenCookie(c, token) + + // Redirect to homepage. + c.Redirect(http.StatusFound, "/") +} + +func AuthorizeUser(c *gin.Context, db *gorm.DB, cfg *config.Config) { + // Check credentials. + username := c.PostForm("username") + password := c.PostForm("password") + _, err := backend.AuthorizeAdmin(db, username, password) + if err != nil { + ShowError(c, err) + return + } + + // Generate an access token. + token, err := GetJWTToken(cfg) + if err != nil { + ShowError(c, err) + return + } + SetTokenCookie(c, token) + + // Redirect to homepage. + c.Redirect(http.StatusFound, "/") +} + +// Save token for 29 days in cookies +func SetTokenCookie(c *gin.Context, token string) { + c.SetCookie("phoenix-token", token, 60*60*24*29, "/", "", false, true) } diff --git a/views/errors.go b/views/errors.go index b911ade..e2d20a7 100644 --- a/views/errors.go +++ b/views/errors.go @@ -13,4 +13,5 @@ func ShowError(c *gin.Context, err error) { "error": err.Error(), }, ) + c.Abort() } diff --git a/views/groups.go b/views/groups.go index 38543e2..eb0137e 100644 --- a/views/groups.go +++ b/views/groups.go @@ -9,10 +9,6 @@ import ( ) func CreateGroup(c *gin.Context, db *gorm.DB) { - if err := RequireAuth(c, db); err != nil { - return - } - // Save new group to the database. group := backend.Group{ Name: c.PostForm("groupName"), @@ -27,10 +23,6 @@ func CreateGroup(c *gin.Context, db *gorm.DB) { } func UpdateGroup(c *gin.Context, db *gorm.DB) { - if err := RequireAuth(c, db); err != nil { - return - } - id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { ShowError(c, err) @@ -54,10 +46,6 @@ func UpdateGroup(c *gin.Context, db *gorm.DB) { } func DeleteGroup(c *gin.Context, db *gorm.DB) { - if err := RequireAuth(c, db); err != nil { - return - } - id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { ShowError(c, err) diff --git a/views/index.go b/views/index.go index d65d0e9..ae0fad6 100644 --- a/views/index.go +++ b/views/index.go @@ -8,10 +8,6 @@ import ( ) func ShowMainPage(c *gin.Context, db *gorm.DB) { - if err := RequireAuth(c, db); err != nil { - return - } - // Get a list of groups with links var groups []backend.Group result := db. diff --git a/views/links.go b/views/links.go index 038bbef..960a4a7 100644 --- a/views/links.go +++ b/views/links.go @@ -9,10 +9,6 @@ import ( ) func CreateLink(c *gin.Context, db *gorm.DB) { - if err := RequireAuth(c, db); err != nil { - return - } - groupID, err := strconv.ParseUint(c.PostForm("groupID"), 10, 32) if err != nil { ShowError(c, err) @@ -34,10 +30,6 @@ func CreateLink(c *gin.Context, db *gorm.DB) { } func UpdateLink(c *gin.Context, db *gorm.DB) { - if err := RequireAuth(c, db); err != nil { - return - } - id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { ShowError(c, err) @@ -62,10 +54,6 @@ func UpdateLink(c *gin.Context, db *gorm.DB) { } func DeleteLink(c *gin.Context, db *gorm.DB) { - if err := RequireAuth(c, db); err != nil { - return - } - id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { ShowError(c, err) diff --git a/views/settings.go b/views/settings.go index 7d5ec72..955224e 100644 --- a/views/settings.go +++ b/views/settings.go @@ -8,10 +8,6 @@ import ( ) func ShowSettings(c *gin.Context, db *gorm.DB) { - if err := RequireAuth(c, db); err != nil { - return - } - // Get a list of groups with links var groups []backend.Group result := db. diff --git a/views/users.go b/views/users.go deleted file mode 100644 index b309f83..0000000 --- a/views/users.go +++ /dev/null @@ -1,77 +0,0 @@ -package views - -import ( - "errors" - "github.com/gin-gonic/gin" - "github.com/ordinary-dev/phoenix/backend" - "gorm.io/gorm" - "net/http" -) - -func CreateUser(c *gin.Context, db *gorm.DB) { - // If at least 1 administator exists, require authorization - if backend.CountAdmins(db) > 0 { - tokenValue, err := c.Cookie("phoenix-token") - - // Anonymous visitor - if err != nil { - err = errors.New("At least 1 user exists, you have to sign in first") - ShowError(c, err) - return - } - - err = backend.ValidateToken(db, tokenValue) - if err != nil { - ShowError(c, err) - return - } - } - - // User is authorized or no user exists. - // Try to create a user. - username := c.PostForm("username") - password := c.PostForm("password") - admin, err := backend.CreateAdmin(db, username, password) - if err != nil { - ShowError(c, err) - return - } - - // Generate access token. - token, err := backend.CreateAccessToken(db, admin.ID) - if err != nil { - ShowError(c, err) - return - } - SetTokenCookie(c, token) - - // Redirect to homepage. - c.Redirect(http.StatusFound, "/") -} - -func AuthorizeUser(c *gin.Context, db *gorm.DB) { - // Check credentials. - username := c.PostForm("username") - password := c.PostForm("password") - admin, err := backend.AuthorizeAdmin(db, username, password) - if err != nil { - ShowError(c, err) - return - } - - // Generate an access token. - token, err := backend.CreateAccessToken(db, admin.ID) - if err != nil { - ShowError(c, err) - return - } - SetTokenCookie(c, token) - - // Redirect to homepage. - c.Redirect(http.StatusFound, "/") -} - -// Save token for 29 days in cookies -func SetTokenCookie(c *gin.Context, token backend.AccessToken) { - c.SetCookie("phoenix-token", token.Value, 60*60*24*29, "/", "", false, true) -}