This commit is contained in:
Stavros
2025-01-23 19:16:35 +02:00
parent 143b13af2c
commit 80d25551e0
16 changed files with 491 additions and 115 deletions

View File

@@ -10,6 +10,7 @@ import (
"tinyauth/internal/assets"
"tinyauth/internal/auth"
"tinyauth/internal/hooks"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"tinyauth/internal/utils"
@@ -20,25 +21,26 @@ import (
"github.com/rs/zerolog/log"
)
func NewAPI(config types.APIConfig, hooks *hooks.Hooks, auth *auth.Auth) (*API) {
func NewAPI(config types.APIConfig, hooks *hooks.Hooks, auth *auth.Auth, providers *providers.Providers) *API {
return &API{
Config: config,
Hooks: hooks,
Auth: auth,
Router: nil,
Config: config,
Hooks: hooks,
Auth: auth,
Providers: providers,
}
}
type API struct {
Config types.APIConfig
Router *gin.Engine
Hooks *hooks.Hooks
Auth *auth.Auth
Config types.APIConfig
Router *gin.Engine
Hooks *hooks.Hooks
Auth *auth.Auth
Providers *providers.Providers
}
func (api *API) Init() {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(zerolog())
dist, distErr := fs.Sub(assets.Assets, "dist")
@@ -67,17 +69,17 @@ func (api *API) Init() {
} else {
isSecure = false
}
store.Options(sessions.Options{
Domain: fmt.Sprintf(".%s", domain),
Path: "/",
HttpOnly: true,
Secure: isSecure,
})
router.Use(sessions.Sessions("tinyauth", store))
router.Use(func(c *gin.Context) {
store.Options(sessions.Options{
Domain: fmt.Sprintf(".%s", domain),
Path: "/",
HttpOnly: true,
Secure: isSecure,
})
router.Use(sessions.Sessions("tinyauth", store))
router.Use(func(c *gin.Context) {
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
_, err := fs.Stat(dist, strings.TrimPrefix(c.Request.URL.Path, "/"))
if os.IsNotExist(err) {
@@ -92,12 +94,20 @@ func (api *API) Init() {
}
func (api *API) SetupRoutes() {
api.Router.GET("/api/auth", func (c *gin.Context) {
userContext := api.Hooks.UseUserContext(c)
api.Router.GET("/api/auth", func(c *gin.Context) {
userContext, userContextErr := api.Hooks.UseUserContext(c)
if userContextErr != nil {
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
if userContext.IsLoggedIn {
c.JSON(200, gin.H{
"status": 200,
"status": 200,
"message": "Authenticated",
})
return
@@ -112,7 +122,7 @@ func (api *API) SetupRoutes() {
if queryErr != nil {
c.JSON(501, gin.H{
"status": 501,
"status": 501,
"message": "Internal Server Error",
})
return
@@ -121,24 +131,24 @@ func (api *API) SetupRoutes() {
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", api.Config.AppURL, queries.Encode()))
})
api.Router.POST("/api/login", func (c *gin.Context) {
api.Router.POST("/api/login", func(c *gin.Context) {
var login types.LoginRequest
err := c.BindJSON(&login)
if err != nil {
c.JSON(400, gin.H{
"status": 400,
"status": 400,
"message": "Bad Request",
})
return
}
user := api.Auth.GetUser(login.Username)
user := api.Auth.GetUser(login.Email)
if user == nil {
c.JSON(401, gin.H{
"status": 401,
"status": 401,
"message": "Unauthorized",
})
return
@@ -146,62 +156,149 @@ func (api *API) SetupRoutes() {
if !api.Auth.CheckPassword(*user, login.Password) {
c.JSON(401, gin.H{
"status": 401,
"status": 401,
"message": "Unauthorized",
})
return
}
session := sessions.Default(c)
session.Set("tinyauth", user.Username)
session.Set("tinyauth_sid", user.Email)
session.Set("tinyauth_oauth_provider", "")
session.Save()
c.JSON(200, gin.H{
"status": 200,
"status": 200,
"message": "Logged in",
})
})
api.Router.POST("/api/logout", func (c *gin.Context) {
api.Router.POST("/api/logout", func(c *gin.Context) {
session := sessions.Default(c)
session.Delete("tinyauth")
session.Delete("tinyauth_sid")
session.Delete("tinyauth_oauth_provider")
session.Save()
c.JSON(200, gin.H{
"status": 200,
"status": 200,
"message": "Logged out",
})
})
api.Router.GET("/api/status", func (c *gin.Context) {
userContext := api.Hooks.UseUserContext(c)
api.Router.GET("/api/status", func(c *gin.Context) {
userContext, userContextErr := api.Hooks.UseUserContext(c)
if userContextErr != nil {
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
if !userContext.IsLoggedIn {
c.JSON(200, gin.H{
"status": 200,
"message": "Unauthenticated",
"username": "",
"status": 200,
"message": "Unauthenticated",
"email": "",
"isLoggedIn": false,
"oauth": false,
"provider": "",
})
return
}
}
c.JSON(200, gin.H{
"status": 200,
"message": "Authenticated",
"username": userContext.Username,
"isLoggedIn": true,
"status": 200,
"message": "Authenticated",
"email": userContext.Email,
"isLoggedIn": userContext.IsLoggedIn,
"oauth": userContext.OAuth,
"provider": userContext.Provider,
})
})
api.Router.GET("/api/healthcheck", func (c *gin.Context) {
api.Router.GET("/api/healthcheck", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": 200,
"status": 200,
"message": "OK",
})
})
}
api.Router.GET("/api/oauth/url/:provider", func(c *gin.Context) {
var provider types.OAuthBind
bindErr := c.BindUri(&provider)
if bindErr != nil {
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
authURL := api.Providers.GetAuthURL(provider.Provider)
if authURL == "" {
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
c.JSON(200, gin.H{
"status": 200,
"message": "Ok",
"url": authURL,
})
})
api.Router.GET("/api/oauth/callback/:provider", func(c *gin.Context) {
var provider types.OAuthBind
bindErr := c.BindUri(&provider)
if bindErr != nil {
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
code := c.Query("code")
if code == "" {
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
email, emailErr := api.Providers.Login(code, provider.Provider)
if emailErr != nil {
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
session := sessions.Default(c)
session.Set("tinyauth_sid", email)
session.Set("tinyauth_oauth_provider", provider.Provider)
session.Save()
c.JSON(200, gin.H{
"status": 200,
"message": "Logged in",
})
})
}
func (api *API) Run() {
log.Info().Str("address", api.Config.Address).Int("port", api.Config.Port).Msg("Starting server")
@@ -218,16 +315,16 @@ func zerolog() gin.HandlerFunc {
address := c.Request.RemoteAddr
method := c.Request.Method
path := c.Request.URL.Path
latency := time.Since(tStart).String()
switch {
case code >= 200 && code < 300:
log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 300 && code < 400:
log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 400:
log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 200 && code < 300:
log.Info().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 300 && code < 400:
log.Warn().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
case code >= 400:
log.Error().Str("method", method).Str("path", path).Str("address", address).Int("status", code).Str("latency", latency).Msg("Request")
}
}
}
}

View File

@@ -16,9 +16,9 @@ type Auth struct {
Users types.Users
}
func (auth *Auth) GetUser(username string) *types.User {
func (auth *Auth) GetUser(email string) *types.User {
for _, user := range auth.Users {
if user.Username == username {
if user.Email == email {
return &user
}
}

View File

@@ -2,53 +2,83 @@ package hooks
import (
"tinyauth/internal/auth"
"tinyauth/internal/providers"
"tinyauth/internal/types"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
func NewHooks(auth *auth.Auth) *Hooks {
func NewHooks(auth *auth.Auth, providers *providers.Providers) *Hooks {
return &Hooks{
Auth: auth,
Auth: auth,
Providers: providers,
}
}
type Hooks struct {
Auth *auth.Auth
Auth *auth.Auth
Providers *providers.Providers
}
func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext) {
func (hooks *Hooks) UseUserContext(c *gin.Context) (types.UserContext, error) {
session := sessions.Default(c)
cookie := session.Get("tinyauth")
sessionCookie := session.Get("tinyauth_sid")
oauthProviderCookie := session.Get("tinyauth_oauth_provider")
if cookie == nil {
if sessionCookie == nil {
return types.UserContext{
Username: "",
Email: "",
IsLoggedIn: false,
}
OAuth: false,
Provider: "",
}, nil
}
username, ok := cookie.(string)
email, emailOk := sessionCookie.(string)
provider, providerOk := oauthProviderCookie.(string)
if !ok {
return types.UserContext{
Username: "",
IsLoggedIn: false,
if provider == "" || !providerOk {
if !emailOk {
return types.UserContext{
Email: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}, nil
}
user := hooks.Auth.GetUser(email)
if user == nil {
return types.UserContext{
Email: "",
IsLoggedIn: false,
OAuth: false,
Provider: "",
}, nil
}
return types.UserContext{
Email: email,
IsLoggedIn: true,
OAuth: false,
Provider: "",
}, nil
}
user := hooks.Auth.GetUser(username)
oauthEmail, oauthEmailErr := hooks.Providers.GetUser(provider)
if user == nil {
if oauthEmailErr != nil {
return types.UserContext{
Username: "",
Email: "",
IsLoggedIn: false,
}
OAuth: false,
Provider: "",
}, nil
}
return types.UserContext{
Username: username,
Email: oauthEmail,
IsLoggedIn: true,
}
}
OAuth: true,
Provider: provider,
}, nil
}

45
internal/oauth/oauth.go Normal file
View File

@@ -0,0 +1,45 @@
package oauth
import (
"context"
"net/http"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
)
func NewOAuth(config oauth2.Config) *OAuth {
return &OAuth{
Config: config,
}
}
type OAuth struct {
Config oauth2.Config
Context context.Context
Token *oauth2.Token
Verifier string
}
func (oauth *OAuth) Init() {
oauth.Context = context.Background()
oauth.Verifier = oauth2.GenerateVerifier()
}
func (oauth *OAuth) GetAuthURL() string {
return oauth.Config.AuthCodeURL("state", oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(oauth.Verifier))
}
func (oauth *OAuth) ExchangeToken(code string) error {
token, err := oauth.Config.Exchange(oauth.Context, code, oauth2.VerifierOption(oauth.Verifier))
if err != nil {
log.Error().Err(err).Msg("Failed to exchange code")
return err
}
oauth.Token = token
return nil
}
func (oauth *OAuth) GetClient() *http.Client {
return oauth.Config.Client(oauth.Context, oauth.Token)
}

View File

@@ -0,0 +1,47 @@
package providers
import (
"encoding/json"
"errors"
"io"
"net/http"
)
type GithubEmailsResponse []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
}
func GithubScopes() ([]string) {
return []string{"user:email"}
}
func GetGithubEmail(client *http.Client) (string, error) {
res, resErr := client.Get("https://api.github.com/user/emails")
if resErr != nil {
return "", resErr
}
body, bodyErr := io.ReadAll(res.Body)
if bodyErr != nil {
return "", bodyErr
}
var emails GithubEmailsResponse
jsonErr := json.Unmarshal(body, &emails)
if jsonErr != nil {
return "", jsonErr
}
for _, email := range emails {
if email.Primary {
return email.Email, nil
}
}
return "", errors.New("no primary email found")
}

View File

@@ -0,0 +1,86 @@
package providers
import (
"tinyauth/internal/oauth"
"tinyauth/internal/types"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2"
"golang.org/x/oauth2/endpoints"
)
func NewProviders(config types.OAuthConfig) *Providers {
return &Providers{
Config: config,
}
}
type Providers struct {
Config types.OAuthConfig
Github *oauth.OAuth
Google *oauth.OAuth
Microsoft *oauth.OAuth
}
func (providers *Providers) Init() {
if providers.Config.GithubClientId != "" && providers.Config.GithubClientSecret != "" {
log.Info().Msg("Initializing Github OAuth")
providers.Github = oauth.NewOAuth(oauth2.Config{
ClientID: providers.Config.GithubClientId,
ClientSecret: providers.Config.GithubClientSecret,
Scopes: GithubScopes(),
Endpoint: endpoints.GitHub,
})
providers.Github.Init()
}
}
func (providers *Providers) Login(code string, provider string) (string, error) {
switch provider {
case "github":
if providers.Github == nil {
return "", nil
}
exchangeErr := providers.Github.ExchangeToken(code)
if exchangeErr != nil {
return "", exchangeErr
}
client := providers.Github.GetClient()
email, emailErr := GetGithubEmail(client)
if emailErr != nil {
return "", emailErr
}
return email, nil
default:
return "", nil
}
}
func (providers *Providers) GetUser(provider string) (string, error) {
switch provider {
case "github":
if providers.Github == nil {
return "", nil
}
client := providers.Github.GetClient()
email, emailErr := GetGithubEmail(client)
if emailErr != nil {
return "", emailErr
}
return email, nil
default:
return "", nil
}
}
func (providers *Providers) GetAuthURL(provider string) string {
switch provider {
case "github":
if providers.Github == nil {
return ""
}
return providers.Github.GetAuthURL()
default:
return ""
}
}

View File

@@ -1,40 +1,74 @@
package types
import "tinyauth/internal/oauth"
type LoginQuery struct {
RedirectURI string `url:"redirect_uri"`
}
type LoginRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
type User struct {
Username string
Email string
Password string
}
type Users []User
type Config struct {
Port int `validate:"number" mapstructure:"port"`
Address string `mapstructure:"address, ip4_addr"`
Secret string `validate:"required,len=32" mapstructure:"secret"`
AppURL string `validate:"required,url" mapstructure:"app-url"`
Users string `mapstructure:"users"`
UsersFile string `mapstructure:"users-file"`
CookieSecure bool `mapstructure:"cookie-secure"`
Port int `validate:"number" mapstructure:"port"`
Address string `mapstructure:"address, ip4_addr"`
Secret string `validate:"required,len=32" mapstructure:"secret"`
AppURL string `validate:"required,url" mapstructure:"app-url"`
Users string `mapstructure:"users"`
UsersFile string `mapstructure:"users-file"`
CookieSecure bool `mapstructure:"cookie-secure"`
GithubClientId string `mapstructure:"github-client-id"`
GithubClientSecret string `mapstructure:"github-client-secret"`
GoogleClientId string `mapstructure:"google-client-id"`
GoogleClientSecret string `mapstructure:"google-client-secret"`
MicrosoftClientId string `mapstructure:"microsoft-client-id"`
MicrosoftClientSecret string `mapstructure:"microsoft-client-secret"`
}
type UserContext struct {
Username string
Email string
IsLoggedIn bool
OAuth bool
Provider string
}
type APIConfig struct {
Port int
Address string
Secret string
AppURL string
Port int
Address string
Secret string
AppURL string
CookieSecure bool
}
}
type OAuthConfig struct {
GithubClientId string
GithubClientSecret string
GoogleClientId string
GoogleClientSecret string
MicrosoftClientId string
MicrosoftClientSecret string
}
type OAuthBind struct {
Provider string `uri:"provider" binding:"required"`
}
type OAuthProviders struct {
Github *oauth.OAuth
Google *oauth.OAuth
Microsoft *oauth.OAuth
}
type OAuthLogin struct {
Email string
Token string
}

View File

@@ -22,7 +22,7 @@ func ParseUsers(users string) (types.Users, error) {
return types.Users{}, errors.New("invalid user format")
}
usersParsed = append(usersParsed, types.User{
Username: userSplit[0],
Email: userSplit[0],
Password: userSplit[1],
})
}