diff options
| author | Bobby <[email protected]> | 2026-02-11 14:53:34 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-02-11 14:53:34 +0530 |
| commit | 3360be86fb6a595659c17f272d0c6072e512c154 (patch) | |
| tree | 4b78aa8120909b596f219a0159a3532200a05b1a | |
| parent | d87e08e0fc5911b2ff40604944448e1f0aaa31b7 (diff) | |
| download | cafe-3360be86fb6a595659c17f272d0c6072e512c154.tar.xz cafe-3360be86fb6a595659c17f272d0c6072e512c154.zip | |
Implement OpenID authentication flow, including user session management and user info retrieval
| -rw-r--r-- | config/config.go | 5 | ||||
| -rw-r--r-- | config/env.go | 20 | ||||
| -rw-r--r-- | controllers/auth.go | 129 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | go.sum | 6 | ||||
| -rw-r--r-- | processors/processors.go | 1 | ||||
| -rw-r--r-- | processors/user.go | 29 | ||||
| -rw-r--r-- | repositories/user.go | 26 | ||||
| -rw-r--r-- | router/auth.go | 4 | ||||
| -rw-r--r-- | services/openid/openid.go | 98 | ||||
| -rw-r--r-- | templates/pages/auth.django | 5 | ||||
| -rw-r--r-- | templates/pages/main.django | 52 | ||||
| -rw-r--r-- | toolchain/docker-compose.dev.yml | 8 | ||||
| -rw-r--r-- | types/openid.go | 13 | ||||
| -rw-r--r-- | utils/auth/auth.go | 2 |
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") } @@ -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 @@ -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) } |
