summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--config/config.go5
-rw-r--r--config/env.go20
-rw-r--r--controllers/auth.go129
-rw-r--r--go.mod3
-rw-r--r--go.sum6
-rw-r--r--processors/processors.go1
-rw-r--r--processors/user.go29
-rw-r--r--repositories/user.go26
-rw-r--r--router/auth.go4
-rw-r--r--services/openid/openid.go98
-rw-r--r--templates/pages/auth.django5
-rw-r--r--templates/pages/main.django52
-rw-r--r--toolchain/docker-compose.dev.yml8
-rw-r--r--types/openid.go13
-rw-r--r--utils/auth/auth.go2
15 files changed, 376 insertions, 25 deletions
diff --git a/config/config.go b/config/config.go
index 41b0f5a..fb056ab 100644
--- a/config/config.go
+++ b/config/config.go
@@ -11,6 +11,7 @@ var (
Server server
Database database
Session session
+ OpenID openid
)
func init() {
@@ -29,4 +30,8 @@ func init() {
if err := env.Parse(&Session); err != nil {
log.Fatalf("Failed to parse SessionConfig: %v", err)
}
+
+ if err := env.Parse(&OpenID); err != nil {
+ log.Fatalf("Failed to parse OpenIDConfig: %v", err)
+ }
}
diff --git a/config/env.go b/config/env.go
index 7bdbfa2..73816b4 100644
--- a/config/env.go
+++ b/config/env.go
@@ -3,13 +3,19 @@ package config
import "time"
type server struct {
- Host string `env:"SERVER_HOST" default:"localhost"`
- Port int `env:"SERVER_PORT" default:"8080"`
- AppSecret string `env:"APP_SECRET" default:"mysecret"`
- AppName string `env:"APP_NAME" default:"Shifoo's Cafe"`
- AppDescription string `env:"APP_DESCRIPTION" default:"A cozy place for close friends"`
- OpenIDDiscoveryURL string `env:"OPENID_DISCOVERY_URL" default:""`
- DevMode bool `env:"DEV_MODE" default:"true"`
+ Host string `env:"SERVER_HOST" default:"localhost"`
+ Port int `env:"SERVER_PORT" default:"8080"`
+ AppSecret string `env:"APP_SECRET" default:"mysecret"`
+ AppName string `env:"APP_NAME" default:"Shifoo's Cafe"`
+ AppDescription string `env:"APP_DESCRIPTION" default:"A cozy place for close friends"`
+ DevMode bool `env:"DEV_MODE" default:"true"`
+}
+
+type openid struct {
+ DiscoveryURL string `env:"OPENID_DISCOVERY_URL" default:""`
+ ClientID string `env:"OPENID_CLIENT_ID" default:""`
+ ClientSecret string `env:"OPENID_CLIENT_SECRET" default:""`
+ CallbackURL string `env:"OPENID_CALLBACK_URL" default:"http://localhost:8080/auth/callback"`
}
type database struct {
diff --git a/controllers/auth.go b/controllers/auth.go
index 2d8d89b..964a22a 100644
--- a/controllers/auth.go
+++ b/controllers/auth.go
@@ -1,19 +1,136 @@
package controllers
import (
+ "cafe/models"
+ "cafe/repositories"
+ "cafe/services/openid"
+ "cafe/session"
"cafe/utils/auth"
- "cafe/utils/meta"
"cafe/utils/shortcuts"
+ "context"
+ "errors"
+ "log"
"github.com/gofiber/fiber/v2"
)
-func Authenticate(context *fiber.Ctx) error {
- if auth.IsAuthenticated(context) {
- return shortcuts.Redirect(context, "mainHall")
+func Login(ctx *fiber.Ctx) error {
+ if auth.IsAuthenticated(ctx) {
+ return shortcuts.Redirect(ctx, "mainHall")
}
- meta.SetPageTitle(context, "Open ID Authentication")
+ state, err := openid.GenerateState()
+ if err != nil {
+ log.Printf("Failed to generate state: %v", err)
+ return InternalServerError(ctx, errors.New("We couldn't start the login process. Please try again."))
+ }
+
+ sess, err := session.Store.Get(ctx)
+ if err != nil {
+ log.Printf("Failed to get session: %v", err)
+ return InternalServerError(ctx, errors.New("There was a problem with your session. Please try logging in again."))
+ }
+ sess.Set("oauth_state", state)
+ if err := sess.Save(); err != nil {
+ log.Printf("Failed to save session: %v", err)
+ return InternalServerError(ctx, errors.New("There was a problem with your session. Please try logging in again."))
+ }
+
+ authURL := openid.GetAuthURL(state)
+ return ctx.Redirect(authURL)
+}
+
+func Callback(ctx *fiber.Ctx) error {
+ state := ctx.Query("state")
+ code := ctx.Query("code")
+
+ if state == "" || code == "" {
+ return BadRequest(ctx, errors.New("The login response was incomplete. Please try logging in again."))
+ }
+
+ sess, err := session.Store.Get(ctx)
+ if err != nil {
+ log.Printf("Failed to get session: %v", err)
+ return InternalServerError(ctx, errors.New("There was a problem with your session. Please try logging in again."))
+ }
+
+ savedState := sess.Get("oauth_state")
+ if savedState == nil || savedState.(string) != state {
+ return Unauthorized(ctx, errors.New("Your login request could not be verified. Please try again."))
+ }
+
+ sess.Delete("oauth_state")
+
+ c := context.Background()
+ oauth2Token, err := openid.ExchangeCode(c, code)
+ if err != nil {
+ log.Printf("Failed to exchange code: %v", err)
+ return Unauthorized(ctx, errors.New("We couldn't verify your login with Cloudron. Please try again."))
+ }
+
+ rawIDToken, ok := oauth2Token.Extra("id_token").(string)
+ if !ok {
+ log.Printf("No id_token in response")
+ return Unauthorized(ctx, errors.New("Your login was incomplete. Please try again."))
+ }
+
+ idToken, err := openid.VerifyIDToken(c, rawIDToken)
+ if err != nil {
+ log.Printf("Failed to verify ID token: %v", err)
+ return Unauthorized(ctx, errors.New("We couldn't verify your identity. Please try logging in again."))
+ }
+
+ userInfo, err := openid.GetUserInfo(c, oauth2Token, idToken)
+ if err != nil {
+ log.Printf("Failed to extract user info: %v", err)
+ return InternalServerError(ctx, errors.New("We couldn't retrieve your account information. Please try again."))
+ }
+
+ user, err := repositories.GetUserByOpenID(userInfo.Sub)
+ isAdminUser := openid.IsAdmin(userInfo)
+
+ if err != nil {
+ user = &models.User{
+ OpenID: userInfo.Sub,
+ Username: userInfo.PreferredUsername,
+ Email: userInfo.Email,
+ DisplayName: userInfo.Name,
+ IsAdmin: isAdminUser,
+ }
+
+ if err := repositories.CreateUser(user); err != nil {
+ log.Printf("Failed to create user: %v", err)
+ return InternalServerError(ctx, errors.New("We couldn't create your account. Please contact an administrator."))
+ }
+ } else {
+ user.Email = userInfo.Email
+ user.DisplayName = userInfo.Name
+ user.IsAdmin = isAdminUser
+ if err := repositories.UpdateUser(user); err != nil {
+ log.Printf("Failed to update user: %v", err)
+ }
+ }
+
+ sess.Set("username", user.Username)
+
+ if err := sess.Save(); err != nil {
+ log.Printf("Failed to save session: %v", err)
+ return InternalServerError(ctx, errors.New("We couldn't complete your login. Please try again."))
+ }
+
+ return shortcuts.Redirect(ctx, "mainHall")
+}
+
+func Logout(ctx *fiber.Ctx) error {
+ sess, err := session.Store.Get(ctx)
+ if err != nil {
+ log.Printf("Failed to get session: %v", err)
+ return shortcuts.Redirect(ctx, "mainHall")
+ }
+
+ if err := sess.Destroy(); err != nil {
+ log.Printf("Failed to destroy session: %v", err)
+ }
- return shortcuts.Render(context, "pages/auth", nil)
+ return shortcuts.Redirect(ctx, "mainHall")
}
diff --git a/go.mod b/go.mod
index 70335e5..d99eb8e 100644
--- a/go.mod
+++ b/go.mod
@@ -3,17 +3,20 @@ module cafe
go 1.25.5
require (
+ github.com/coreos/go-oidc/v3 v3.17.0
github.com/flosch/pongo2/v6 v6.0.0
github.com/gofiber/fiber/v2 v2.52.10
github.com/gofiber/storage/postgres/v3 v3.3.1
github.com/gofiber/template/django/v3 v3.1.14
github.com/joho/godotenv v1.5.1
+ golang.org/x/oauth2 v0.35.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
+ github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
diff --git a/go.sum b/go.sum
index 3bae5ad..b7b8854 100644
--- a/go.sum
+++ b/go.sum
@@ -16,6 +16,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
+github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
+github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -35,6 +37,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
+github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
+github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -151,6 +155,8 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/processors/processors.go b/processors/processors.go
index d56cbde..f1d573e 100644
--- a/processors/processors.go
+++ b/processors/processors.go
@@ -5,4 +5,5 @@ import "github.com/gofiber/fiber/v2"
func Initialize(app *fiber.App) {
app.Use(metadata)
app.Use(request)
+ app.Use(user)
}
diff --git a/processors/user.go b/processors/user.go
new file mode 100644
index 0000000..171808f
--- /dev/null
+++ b/processors/user.go
@@ -0,0 +1,29 @@
+package processors
+
+import (
+ "cafe/repositories"
+ "cafe/session"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func user(ctx *fiber.Ctx) error {
+ sess, err := session.Store.Get(ctx)
+ if err != nil {
+ return ctx.Next()
+ }
+
+ username := sess.Get("username")
+ if username == nil {
+ return ctx.Next()
+ }
+
+ user, err := repositories.GetUserByUsername(username.(string))
+ if err != nil {
+ return ctx.Next()
+ }
+
+ ctx.Locals("User", user)
+
+ return ctx.Next()
+}
diff --git a/repositories/user.go b/repositories/user.go
new file mode 100644
index 0000000..0ab5e1b
--- /dev/null
+++ b/repositories/user.go
@@ -0,0 +1,26 @@
+package repositories
+
+import (
+ "cafe/database"
+ "cafe/models"
+)
+
+func GetUserByUsername(username string) (*models.User, error) {
+ var user models.User
+ err := database.DB.Where("username = ?", username).First(&user).Error
+ return &user, err
+}
+
+func GetUserByOpenID(openID string) (*models.User, error) {
+ var user models.User
+ err := database.DB.Where("open_id = ?", openID).First(&user).Error
+ return &user, err
+}
+
+func CreateUser(user *models.User) error {
+ return database.DB.Create(user).Error
+}
+
+func UpdateUser(user *models.User) error {
+ return database.DB.Save(user).Error
+}
diff --git a/router/auth.go b/router/auth.go
index 21b9d9f..ba65e49 100644
--- a/router/auth.go
+++ b/router/auth.go
@@ -9,5 +9,7 @@ import (
func init() {
urls.SetNamespace("auth")
- urls.Path(types.GET, "/", controllers.Authenticate, "authenticate")
+ urls.Path(types.GET, "/login", controllers.Login, "login")
+ urls.Path(types.GET, "/callback", controllers.Callback, "callback")
+ urls.Path(types.GET, "/logout", controllers.Logout, "logout")
}
diff --git a/services/openid/openid.go b/services/openid/openid.go
new file mode 100644
index 0000000..e02c1b7
--- /dev/null
+++ b/services/openid/openid.go
@@ -0,0 +1,98 @@
+package openid
+
+import (
+ "cafe/config"
+ "cafe/types"
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "log"
+ "slices"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "golang.org/x/oauth2"
+)
+
+var (
+ Provider *oidc.Provider
+ OAuth2Config *oauth2.Config
+ Verifier *oidc.IDTokenVerifier
+)
+
+func init() {
+ if config.OpenID.DiscoveryURL == "" {
+ log.Fatal("OPENID_DISCOVERY_URL not configured. OpenID authentication is required.")
+ }
+
+ ctx := context.Background()
+ var err error
+
+ Provider, err = oidc.NewProvider(ctx, config.OpenID.DiscoveryURL)
+ if err != nil {
+ log.Fatalf("Failed to initialize OpenID provider: %v", err)
+ }
+
+ OAuth2Config = &oauth2.Config{
+ ClientID: config.OpenID.ClientID,
+ ClientSecret: config.OpenID.ClientSecret,
+ RedirectURL: config.OpenID.CallbackURL,
+ Endpoint: Provider.Endpoint(),
+ Scopes: []string{oidc.ScopeOpenID, "email", "profile", "groups"},
+ }
+
+ Verifier = Provider.Verifier(&oidc.Config{
+ ClientID: config.OpenID.ClientID,
+ })
+
+ log.Println("OpenID Connect provider initialized successfully")
+}
+
+func GenerateState() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.URLEncoding.EncodeToString(b), nil
+}
+
+func GetAuthURL(state string) string {
+ return OAuth2Config.AuthCodeURL(state)
+}
+
+func ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) {
+ return OAuth2Config.Exchange(ctx, code)
+}
+
+func VerifyIDToken(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
+ return Verifier.Verify(ctx, rawIDToken)
+}
+
+func GetUserInfo(ctx context.Context, token *oauth2.Token, idToken *oidc.IDToken) (*types.UserInfo, error) {
+ var userInfo types.UserInfo
+ if err := idToken.Claims(&userInfo); err != nil {
+ return nil, fmt.Errorf("failed to parse ID token claims: %v", err)
+ }
+
+ userInfoEndpoint, err := Provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
+ if err != nil {
+ log.Printf("Warning: Failed to fetch additional user info from userinfo endpoint: %v", err)
+ return &userInfo, nil
+ }
+
+ var additionalClaims types.UserInfo
+ if err := userInfoEndpoint.Claims(&additionalClaims); err != nil {
+ log.Printf("Warning: Failed to parse userinfo endpoint claims into UserInfo: %v", err)
+ return &userInfo, nil
+ }
+
+ if len(additionalClaims.Groups) > 0 {
+ userInfo.Groups = additionalClaims.Groups
+ }
+
+ return &userInfo, nil
+}
+
+func IsAdmin(userInfo *types.UserInfo) bool {
+ return slices.Contains(userInfo.Groups, "administrator")
+}
diff --git a/templates/pages/auth.django b/templates/pages/auth.django
deleted file mode 100644
index 2c4c372..0000000
--- a/templates/pages/auth.django
+++ /dev/null
@@ -1,5 +0,0 @@
-{% extends "layouts/base.django" %}
-
-{% block content %}
-You will be authenticated here.
-{% endblock %} \ No newline at end of file
diff --git a/templates/pages/main.django b/templates/pages/main.django
index 429ffbf..a746024 100644
--- a/templates/pages/main.django
+++ b/templates/pages/main.django
@@ -1,5 +1,55 @@
{% extends "layouts/base.django" %}
{% block content %}
-This will be cafe.
+<div class="flex h-screen">
+ <div class="bg-[#16213e] w-16 flex flex-col items-center py-4">
+ <div class="text-2xl mb-4">☕</div>
+ </div>
+
+ <div class="flex-1 flex flex-col">
+ <div class="bg-[#16213e] border-b border-[#0f3460] px-6 py-4 flex justify-between items-center">
+ <h1 class="text-xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-600">
+ Main Hall
+ </h1>
+ <div class="flex items-center gap-4">
+ {% if User %}
+ <span class="text-gray-300">👋 {{ User.Username }}</span>
+ <a href="{% url "auth.logout" %}"
+ class="text-sm bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded transition-colors">
+ Logout
+ </a>
+ {% endif %}
+ </div>
+ </div>
+
+ <div class="flex-1 p-6">
+ <div class="max-w-4xl mx-auto">
+ <div class="bg-[#16213e] rounded-lg shadow-xl p-8 border border-[#0f3460]">
+ <h2 class="text-2xl font-bold mb-4 text-white">Welcome to Shifoo's Cafe! ☕</h2>
+ <p class="text-gray-400 mb-4">
+ You're now authenticated. This is where the main application will be built.
+ </p>
+
+ {% if User %}
+ <div class="mt-6 p-4 bg-[#0f3460]/50 rounded border border-[#0f3460]">
+ <h3 class="text-lg font-semibold text-white mb-2">Your User Info:</h3>
+ <ul class="text-gray-300 space-y-1">
+ <li><strong>Username:</strong> {{ User.Username }}</li>
+ <li><strong>Email:</strong> {{ User.Email }}</li>
+ <li><strong>Display Name:</strong> {{ User.DisplayName }}</li>
+ <li><strong>Admin:</strong> {% if User.IsAdmin %}Yes ⭐{% else %}No{% endif %}</li>
+ </ul>
+ </div>
+ {% endif %}
+
+ <div class="mt-8 p-4 bg-purple-900/20 border border-purple-700/50 rounded">
+ <p class="text-purple-300 text-sm">
+ 🚧 The Discord-like interface with Rooms, Lounges, Whispers, and more will be built here!
+ </p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
{% endblock %} \ No newline at end of file
diff --git a/toolchain/docker-compose.dev.yml b/toolchain/docker-compose.dev.yml
index caba96a..f87ea5a 100644
--- a/toolchain/docker-compose.dev.yml
+++ b/toolchain/docker-compose.dev.yml
@@ -5,13 +5,13 @@ services:
ports:
- "5432:5432"
environment:
- POSTGRES_DB: cafe_dev
- POSTGRES_USER: cafe_dev
- POSTGRES_PASSWORD: dev_password
+ POSTGRES_DB: cafe
+ POSTGRES_USER: cafe
+ POSTGRES_PASSWORD: cafe
volumes:
- postgres_dev_data:/var/lib/postgresql/data
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U cafe_dev"]
+ test: ["CMD-SHELL", "pg_isready -U cafe"]
interval: 5s
timeout: 3s
retries: 5
diff --git a/types/openid.go b/types/openid.go
new file mode 100644
index 0000000..e344c6a
--- /dev/null
+++ b/types/openid.go
@@ -0,0 +1,13 @@
+package types
+
+type UserInfo struct {
+ Sub string `json:"sub"`
+ Email string `json:"email"`
+ EmailVerified bool `json:"email_verified"`
+ Name string `json:"name"`
+ PreferredUsername string `json:"preferred_username"`
+ GivenName string `json:"given_name"`
+ FamilyName string `json:"family_name"`
+ Picture string `json:"picture"`
+ Groups []string `json:"groups"`
+}
diff --git a/utils/auth/auth.go b/utils/auth/auth.go
index 3cee3a4..9f6e79d 100644
--- a/utils/auth/auth.go
+++ b/utils/auth/auth.go
@@ -20,7 +20,7 @@ func IsAuthenticated(context *fiber.Ctx) bool {
func RequireAuthentication(handler fiber.Handler) fiber.Handler {
return func(context *fiber.Ctx) error {
if !IsAuthenticated(context) {
- return shortcuts.Redirect(context, "auth.authenticate")
+ return shortcuts.Redirect(context, "auth.login")
}
return handler(context)
}