Start using JWT tokens

I thought this was a good idea.
Pros: fewer database calls.
Cons: there is no way to revoke the token (except for changing the secret key).

I rewrote the authorization as a middleware. Request handlers no longer need to validate the user.
This commit is contained in:
Ivan R. 2023-07-22 13:42:43 +05:00
parent 7f42a90be6
commit 2c08171c7a
No known key found for this signature in database
GPG key ID: 56C7BAAE859B302C
12 changed files with 126 additions and 189 deletions

View file

@ -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
}

View file

@ -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
}

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

36
main.go
View file

@ -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)
})

View file

@ -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)
}

View file

@ -13,4 +13,5 @@ func ShowError(c *gin.Context, err error) {
"error": err.Error(),
},
)
c.Abort()
}

View file

@ -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)

View file

@ -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.

View file

@ -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)

View file

@ -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.

View file

@ -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)
}