@@ -25,6 +25,7 @@ email:
|
|||||||
secrets:
|
secrets:
|
||||||
jwt_secret: example
|
jwt_secret: example
|
||||||
turnstile_secret: example
|
turnstile_secret: example
|
||||||
|
client_secret_key: example
|
||||||
ttl:
|
ttl:
|
||||||
magic_link_ttl: 10m
|
magic_link_ttl: 10m
|
||||||
access_ttl: 15s
|
access_ttl: 15s
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type email struct {
|
|||||||
type secrets struct {
|
type secrets struct {
|
||||||
JwtSecret string `yaml:"jwt_secret"`
|
JwtSecret string `yaml:"jwt_secret"`
|
||||||
TurnstileSecret string `yaml:"turnstile_secret"`
|
TurnstileSecret string `yaml:"turnstile_secret"`
|
||||||
|
ClientSecretKey string `yaml:"client_secret_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ttl struct {
|
type ttl struct {
|
||||||
|
|||||||
@@ -1 +1,91 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"nixcn-cms/internal/cryptography"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
Id uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
|
||||||
|
ClientId string `json:"client_id" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||||
|
ClientSecret string `json:"client_secret" gorm:"type:varchar(255);not null"`
|
||||||
|
ClientName string `json:"client_name" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||||
|
RedirectUri datatypes.JSON `json:"redirect_uri" gorm:"type:json;not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Client) GetClientByClientId(clientId string) (*Client, error) {
|
||||||
|
var client Client
|
||||||
|
if err := Database.
|
||||||
|
Where("client_id = ?", clientId).
|
||||||
|
First(&client).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Client) GetDecryptedSecret() (string, error) {
|
||||||
|
secretKey := viper.GetString("secrets.client_secret_key")
|
||||||
|
secret, err := cryptography.AESCBCDecrypt(self.ClientSecret, []byte(secretKey))
|
||||||
|
return string(secret), err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientParams struct {
|
||||||
|
ClientId string
|
||||||
|
ClientName string
|
||||||
|
RedirectUri []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Client) Create(params *ClientParams) (*Client, error) {
|
||||||
|
jsonRedirectUri, err := json.Marshal(params.RedirectUri)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
encKey := viper.GetString("secrets.client_secret_key")
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clientSecret := base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
encryptedSecret, err := cryptography.AESCBCEncrypt([]byte(clientSecret), []byte(encKey))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
UUID: uuid.New(),
|
||||||
|
ClientId: params.ClientId,
|
||||||
|
ClientSecret: encryptedSecret,
|
||||||
|
ClientName: params.ClientName,
|
||||||
|
RedirectUri: jsonRedirectUri,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Database.Create(&client).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Client) ValidateRedirectURI(redirectURI string) error {
|
||||||
|
var uris []string
|
||||||
|
if err := json.Unmarshal(self.RedirectUri, &uris); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range uris {
|
||||||
|
if strings.HasPrefix(redirectURI, prefix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("redirect uri not match")
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func Init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto migrate
|
// Auto migrate
|
||||||
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{})
|
err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}, &Client{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[Database] Error migrating database: ", err)
|
log.Error("[Database] Error migrating database: ", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"nixcn-cms/internal/cryptography"
|
"nixcn-cms/pkgs/authtoken"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -11,8 +11,8 @@ func JWTAuth(required bool) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
auth := c.GetHeader("Authorization")
|
auth := c.GetHeader("Authorization")
|
||||||
|
|
||||||
token := new(cryptography.Token)
|
authtoken := new(authtoken.Token)
|
||||||
uid, err := token.HeaderVerify(auth)
|
uid, err := authtoken.HeaderVerify(auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(401, gin.H{"status": err.Error()})
|
c.JSON(401, gin.H{"status": err.Error()})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
|||||||
@@ -25,29 +25,29 @@ func NewAuthCode(email string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
token := base64.RawURLEncoding.EncodeToString(b)
|
code := base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
|
||||||
store.Store(token, Token{
|
store.Store(code, Token{
|
||||||
Email: email,
|
Email: email,
|
||||||
ExpiresAt: time.Now().Add(viper.GetDuration("ttl.magic_link_ttl")),
|
ExpiresAt: time.Now().Add(viper.GetDuration("ttl.magic_link_ttl")),
|
||||||
})
|
})
|
||||||
|
|
||||||
return token, nil
|
return code, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify magic token
|
// Verify magic token
|
||||||
func VerifyAuthCode(token string) (string, bool) {
|
func VerifyAuthCode(code string) (string, bool) {
|
||||||
val, ok := store.Load(token)
|
val, ok := store.Load(code)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
t := val.(Token)
|
t := val.(Token)
|
||||||
if time.Now().After(t.ExpiresAt) {
|
if time.Now().After(t.ExpiresAt) {
|
||||||
store.Delete(token)
|
store.Delete(code)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
store.Delete(token)
|
store.Delete(code)
|
||||||
return t.Email, true
|
return t.Email, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package cryptography
|
package authtoken
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"nixcn-cms/middleware"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
func Handler(r *gin.RouterGroup) {
|
func Handler(r *gin.RouterGroup) {
|
||||||
r.GET("/redirect", Redirect)
|
r.GET("/redirect", Redirect, middleware.JWTAuth(false))
|
||||||
r.POST("/magic", Magic)
|
r.POST("/magic", Magic)
|
||||||
r.POST("/refresh", Refresh)
|
r.POST("/refresh", Refresh)
|
||||||
r.POST("/token", Token)
|
r.POST("/token", Token)
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"nixcn-cms/data"
|
"net/url"
|
||||||
"nixcn-cms/internal/cryptography"
|
|
||||||
"nixcn-cms/pkgs/authcode"
|
"nixcn-cms/pkgs/authcode"
|
||||||
"nixcn-cms/pkgs/email"
|
"nixcn-cms/pkgs/email"
|
||||||
"nixcn-cms/pkgs/turnstile"
|
"nixcn-cms/pkgs/turnstile"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -43,15 +40,23 @@ func Magic(c *gin.Context) {
|
|||||||
c.JSON(500, gin.H{"status": "code gen failed"})
|
c.JSON(500, gin.H{"status": "code gen failed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
uri := viper.GetString("server.external_url") +
|
externalUrl := viper.GetString("server.external_url")
|
||||||
"/api/v1/auth/redirect?" +
|
url, err := url.Parse(externalUrl)
|
||||||
"code=" + code +
|
if err != nil {
|
||||||
"&redirect_uri=" + req.RedirectUri +
|
c.JSON(500, gin.H{"status": "invalid external url"})
|
||||||
"&state=" + req.State
|
}
|
||||||
|
|
||||||
debugMode := viper.GetString("server.debug_mode")
|
url.Path = "/api/v1/auth/redirect"
|
||||||
if debugMode == "true" {
|
query := url.Query()
|
||||||
log.Info("Magic link for " + req.Email + " : " + uri)
|
query.Set("code", code)
|
||||||
|
query.Set("redirect_uri", req.RedirectUri)
|
||||||
|
query.Set("state", req.State)
|
||||||
|
url.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
debugMode := viper.GetBool("server.debug_mode")
|
||||||
|
if debugMode {
|
||||||
|
c.JSON(200, gin.H{"status": "magiclink sent", "uri": url.String()})
|
||||||
|
return
|
||||||
} else {
|
} else {
|
||||||
// Send email using resend
|
// Send email using resend
|
||||||
resend, err := email.NewResendClient()
|
resend, err := email.NewResendClient()
|
||||||
@@ -63,61 +68,9 @@ func Magic(c *gin.Context) {
|
|||||||
resend.Send(
|
resend.Send(
|
||||||
req.Email,
|
req.Email,
|
||||||
"NixCN CMS Email Verify",
|
"NixCN CMS Email Verify",
|
||||||
"<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+uri+">"+uri+"</a>",
|
"<p>Click the link below to verify your email. This link will expire in 10 minutes.</p><a href="+url.String()+">"+url.String()+"</a>",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, gin.H{"status": "magic link sent"})
|
c.JSON(200, gin.H{"status": "magic link sent"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func VerifyMagicLink(c *gin.Context) {
|
|
||||||
// Get token from url
|
|
||||||
magicToken := c.Query("token")
|
|
||||||
if magicToken == "" {
|
|
||||||
c.JSON(400, gin.H{"error": "missing token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify email token
|
|
||||||
email, ok := authcode.VerifyAuthCode(magicToken)
|
|
||||||
if !ok {
|
|
||||||
c.JSON(401, gin.H{"error": "invalid or expired token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify if user exists
|
|
||||||
userData := new(data.User)
|
|
||||||
user, err := userData.GetByEmail(email)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
// Create user
|
|
||||||
user.UUID = uuid.New()
|
|
||||||
user.UserId = uuid.New()
|
|
||||||
user.Email = email
|
|
||||||
user.PermissionLevel = 10
|
|
||||||
if err := user.Create(); err != nil {
|
|
||||||
c.JSON(500, gin.H{"status": "internal server error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
c.JSON(500, gin.H{"status": "internal server error"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate jwt
|
|
||||||
JwtTool := cryptography.Token{
|
|
||||||
Application: viper.GetString("server.application"),
|
|
||||||
}
|
|
||||||
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(500, gin.H{"status": "error generating tokens"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"access_token": accessToken,
|
|
||||||
"refresh_token": refreshToken,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,122 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"net/url"
|
||||||
|
"nixcn-cms/data"
|
||||||
|
"nixcn-cms/pkgs/authcode"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
func Redirect(c *gin.Context) {
|
func Redirect(c *gin.Context) {
|
||||||
|
clientId := c.Query("client_id")
|
||||||
|
if clientId == "" {
|
||||||
|
c.JSON(400, gin.H{"status": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectUri := c.Query("redirect_uri")
|
||||||
|
if redirectUri == "" {
|
||||||
|
c.JSON(400, gin.H{"status": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := c.Query("state")
|
||||||
|
if state == "" {
|
||||||
|
c.JSON(400, gin.H{"status": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := c.Query("code")
|
||||||
|
if code == "" {
|
||||||
|
userIdOrig, ok := c.Get("user_id")
|
||||||
|
if !ok || userIdOrig == "" {
|
||||||
|
c.JSON(401, gin.H{"status": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId, err := uuid.Parse(userIdOrig.(string))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"status": "failed to parse uuid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userData := new(data.User)
|
||||||
|
user, err := userData.GetByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"status": "failed to get user id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := authcode.NewAuthCode(user.Email)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"status": "code gen failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := url.Parse(redirectUri)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"status": "invalid redirect uri"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := url.Query()
|
||||||
|
query.Set("code", code)
|
||||||
|
url.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
c.Redirect(302, url.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify email token
|
||||||
|
email, ok := authcode.VerifyAuthCode(code)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(403, gin.H{"status": "invalid or expired token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify if user exists
|
||||||
|
userData := new(data.User)
|
||||||
|
user, err := userData.GetByEmail(email)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
// Create user
|
||||||
|
user.UUID = uuid.New()
|
||||||
|
user.UserId = uuid.New()
|
||||||
|
user.Email = email
|
||||||
|
user.PermissionLevel = 10
|
||||||
|
if err := user.Create(); err != nil {
|
||||||
|
c.JSON(500, gin.H{"status": "internal server error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(500, gin.H{"status": "internal server error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clientData := new(data.Client)
|
||||||
|
client, err := clientData.GetClientByClientId(clientId)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"status": "client not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.ValidateRedirectURI(redirectUri)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"status": "redirect uri not match"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := url.Parse(redirectUri)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"status": "invalid redirect uri"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := url.Query()
|
||||||
|
query.Set("code", code)
|
||||||
|
url.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
c.Redirect(302, url.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"nixcn-cms/internal/cryptography"
|
"nixcn-cms/pkgs/authtoken"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -17,7 +17,7 @@ func Refresh(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
JwtTool := cryptography.Token{
|
JwtTool := authtoken.Token{
|
||||||
Application: viper.GetString("server.application"),
|
Application: viper.GetString("server.application"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,52 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import "github.com/gin-gonic/gin"
|
import (
|
||||||
|
"nixcn-cms/data"
|
||||||
|
"nixcn-cms/pkgs/authcode"
|
||||||
|
"nixcn-cms/pkgs/authtoken"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenRequest struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
func Token(c *gin.Context) {
|
func Token(c *gin.Context) {
|
||||||
|
var req TokenRequest
|
||||||
|
|
||||||
|
err := c.ShouldBindJSON(&req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"status": "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email, ok := authcode.VerifyAuthCode(req.Code)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(403, gin.H{"status": "invalid or expired token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userData := new(data.User)
|
||||||
|
user, err := userData.GetByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"status": "internal server error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate jwt
|
||||||
|
JwtTool := authtoken.Token{
|
||||||
|
Application: viper.GetString("server.application"),
|
||||||
|
}
|
||||||
|
accessToken, refreshToken, err := JwtTool.IssueTokens(user.UserId)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"status": "error generating tokens"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(200, gin.H{
|
||||||
|
"access_token": accessToken,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user