initial commit
This commit is contained in:
commit
b0309018ad
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# ---> Go
|
||||||
|
# If you prefer the allow list template instead of the deny list, see community template:
|
||||||
|
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||||
|
#
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# env file
|
||||||
|
.env
|
||||||
|
|
9
LICENSE
Normal file
9
LICENSE
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 andreas
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
39
README.md
Normal file
39
README.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Go Gin OAuth2 Demo with Keycloak
|
||||||
|
|
||||||
|
This is a minimalist demo project illustrating the integration of [**OAuth2**](https://oauth.net/2/) (provided by a [**Keycloak**](https://www.keycloak.org/) server) into a [**Gin**](https://github.com/gin-gonic/gin)-based Go application. The implementation focuses on simplicity and separation of concerns, ensuring that the core view functions remain clean and free from authentication logic.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Middleware-Driven OAuth2 Handling**: Authentication and authorization are managed entirely through middleware, keeping the view functions decoupled from OAuth2 logic.
|
||||||
|
- **User Import from Keycloak**: Users are imported directly from Keycloak, leveraging its identity management capabilities.
|
||||||
|
- **Token Management**: Access tokens are stored in cookies and automatically refreshed before expiration to ensure seamless user sessions.
|
||||||
|
- **Protected Routes**: Sensitive URLs (e.g., for modifying or deleting data) can be marked as protected. When accessed, an **introspection** is performed to validate the user's permissions.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. **Prerequisites**:
|
||||||
|
- Go >=1.23.6 installed.
|
||||||
|
- A running Keycloak server with a configured realm and client.
|
||||||
|
|
||||||
|
2. **Installation**:
|
||||||
|
```sh
|
||||||
|
git clone https://git.0x0001f346.de/andreas/gin-oauth2-demo.git
|
||||||
|
cd gin-oauth2-demo
|
||||||
|
go mod tidy
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configuration**:
|
||||||
|
- Update the `middleware.go` file with your domain and Keycloak server details (client ID, client secret, realm, etc.).
|
||||||
|
|
||||||
|
4. **Run the Application**:
|
||||||
|
```sh
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Access the Application**:
|
||||||
|
- Open your browser and navigate to `http://localhost:9000`.
|
||||||
|
- Log in via Keycloak and explore the protected routes.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
22
core/core.go
Normal file
22
core/core.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
UUID string `json:"sub"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
PreferredUsername string `json:"preferred_username"`
|
||||||
|
GivenName string `json:"given_name"`
|
||||||
|
FamilyName string `json:"family_name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) HasGroupMembership(targetGroup string) bool {
|
||||||
|
for _, group := range u.Groups {
|
||||||
|
if group == targetGroup {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
3
go.mod
Normal file
3
go.mod
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module git.0x0001f346.de/andreas/gin-oauth2-demo
|
||||||
|
|
||||||
|
go 1.23.6
|
62
main.go
Normal file
62
main.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.0x0001f346.de/andreas/gin-oauth2-demo/core"
|
||||||
|
"git.0x0001f346.de/andreas/gin-oauth2-demo/middleware"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const urlRoot string = "/"
|
||||||
|
const urlSecret string = "/secret"
|
||||||
|
const urlSecretWithNumber string = "/secret/:num"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
router := gin.Default()
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
|
router.ForwardedByClientIP = true
|
||||||
|
router.SetTrustedProxies([]string{"127.0.0.1"})
|
||||||
|
|
||||||
|
router.NoRoute(func(c *gin.Context) { c.Redirect(http.StatusSeeOther, "/") })
|
||||||
|
router.NoMethod(func(c *gin.Context) { c.Redirect(http.StatusSeeOther, "/") })
|
||||||
|
|
||||||
|
router.Use(middleware.Auth())
|
||||||
|
groupAuth := router.Group(middleware.URLPrefix)
|
||||||
|
middleware.SetupRoutes(groupAuth)
|
||||||
|
middleware.ProtectURL(urlSecret)
|
||||||
|
middleware.ProtectURL(urlSecretWithNumber)
|
||||||
|
|
||||||
|
router.GET(urlRoot, viewRoot)
|
||||||
|
router.GET(urlSecret, viewSecret)
|
||||||
|
router.GET(urlSecretWithNumber, viewSecretWithNumber)
|
||||||
|
|
||||||
|
log.Fatal(router.Run(":9000"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewRoot(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user": c.MustGet("user").(core.User),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewSecret(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"secret text": "try to add a number to this URL",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewSecretWithNumber(c *gin.Context) {
|
||||||
|
num, err := strconv.Atoi(c.Param("num"))
|
||||||
|
if err != nil {
|
||||||
|
c.Redirect(http.StatusSeeOther, urlSecret)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"secret id": num,
|
||||||
|
})
|
||||||
|
}
|
431
middleware/middleware.go
Normal file
431
middleware/middleware.go
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.0x0001f346.de/andreas/gin-oauth2-demo/core"
|
||||||
|
"git.0x0001f346.de/andreas/gin-oauth2-demo/repository"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type introspectionResponse struct {
|
||||||
|
Exp int `json:"exp,omitempty"`
|
||||||
|
Iat int `json:"iat,omitempty"`
|
||||||
|
AuthTime int `json:"auth_time,omitempty"`
|
||||||
|
Jti string `json:"jti,omitempty"`
|
||||||
|
Iss string `json:"iss,omitempty"`
|
||||||
|
Aud string `json:"aud,omitempty"`
|
||||||
|
Sub string `json:"sub,omitempty"`
|
||||||
|
Typ string `json:"typ,omitempty"`
|
||||||
|
Azp string `json:"azp,omitempty"`
|
||||||
|
Sid string `json:"sid,omitempty"`
|
||||||
|
Acr string `json:"acr,omitempty"`
|
||||||
|
AllowedOrigins []string `json:"allowed-origins,omitempty"`
|
||||||
|
RealmAccess struct {
|
||||||
|
Roles []string `json:"roles,omitempty"`
|
||||||
|
} `json:"realm_access,omitempty"`
|
||||||
|
ResourceAccess struct {
|
||||||
|
Account struct {
|
||||||
|
Roles []string `json:"roles,omitempty"`
|
||||||
|
} `json:"account,omitempty"`
|
||||||
|
} `json:"resource_access,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
EmailVerified bool `json:"email_verified,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Groups []string `json:"groups,omitempty"`
|
||||||
|
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||||
|
GivenName string `json:"given_name,omitempty"`
|
||||||
|
FamilyName string `json:"family_name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
TokenType string `json:"token_type,omitempty"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// public
|
||||||
|
const URLPrefix string = "/auth"
|
||||||
|
|
||||||
|
// private
|
||||||
|
const accessGroupNeededForThisApp string = "db-users"
|
||||||
|
|
||||||
|
const minTokenValiditySeconds float64 = 60 * 60 * 12
|
||||||
|
|
||||||
|
const nameHTTPCookie string = "token"
|
||||||
|
|
||||||
|
const urlAppCallback string = "/callback"
|
||||||
|
const urlAppLogin string = "/login"
|
||||||
|
const urlAppLogout string = "/logout"
|
||||||
|
|
||||||
|
const urlKeycloakAuth string = "https://auth.mydomain.tld/realms/myrealm/protocol/openid-connect/auth"
|
||||||
|
const urlKeycloakIOntrospect string = "https://auth.mydomain.tld/realms/myrealm/protocol/openid-connect/token/introspect"
|
||||||
|
const urlKeycloakLogout string = "https://auth.mydomain.tld/realms/myrealm/protocol/openid-connect/logout"
|
||||||
|
const urlKeycloakToken string = "https://auth.mydomain.tld/realms/myrealm/protocol/openid-connect/token"
|
||||||
|
const urlKeycloakUserinfo string = "https://auth.mydomain.tld/realms/myrealm/protocol/openid-connect/userinfo"
|
||||||
|
|
||||||
|
var oAuthConfig = oauth2.Config{
|
||||||
|
ClientID: "db.mydomain.tld",
|
||||||
|
ClientSecret: "a3wFLNGDiNHyNLUM7peYLL97JBkE3ltk",
|
||||||
|
RedirectURL: fmt.Sprintf("https://db.mydomain.tld%s", GetURLCallback()),
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: urlKeycloakAuth,
|
||||||
|
TokenURL: urlKeycloakToken,
|
||||||
|
},
|
||||||
|
Scopes: []string{"groups", "openid", "profile", "email"},
|
||||||
|
}
|
||||||
|
|
||||||
|
var protectedURLs map[string]bool = map[string]bool{}
|
||||||
|
|
||||||
|
func Auth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if isRouteWithoutAuth(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := getAccessTokenFromRequest(c)
|
||||||
|
if err != nil {
|
||||||
|
deleteCookieAndRedirectToLogin(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("accessToken", accessToken)
|
||||||
|
|
||||||
|
user, err := getUserForAccessToken(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
deleteCookieAndRedirectToLogin(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("user", user)
|
||||||
|
|
||||||
|
err = refreshTokenIfNecessary(c)
|
||||||
|
if err != nil {
|
||||||
|
deleteCookieAndRedirectToLogin(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
protectURLIfNecessary(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetURLCallback() string {
|
||||||
|
return fmt.Sprintf("%s%s", URLPrefix, urlAppCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetURLLogin() string {
|
||||||
|
return fmt.Sprintf("%s%s", URLPrefix, urlAppLogin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetURLLogout() string {
|
||||||
|
return fmt.Sprintf("%s%s", URLPrefix, urlAppLogout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProtectURL(url string) {
|
||||||
|
protectedURLs[url] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupRoutes(rg *gin.RouterGroup) {
|
||||||
|
rg.GET(urlAppCallback, routeCallback)
|
||||||
|
rg.GET(urlAppLogin, routeLogin)
|
||||||
|
rg.GET(urlAppLogout, routeLogout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCookieAndRedirectToLogin(c *gin.Context) {
|
||||||
|
c.SetCookie(nameHTTPCookie, "", -1, "/", "", false, true)
|
||||||
|
c.Redirect(http.StatusSeeOther, GetURLLogin())
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchUserFromKeycloak(accessToken string) (core.User, error) {
|
||||||
|
req, err := http.NewRequest("GET", urlKeycloakUserinfo, nil)
|
||||||
|
if err != nil {
|
||||||
|
return core.User{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return core.User{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return core.User{}, fmt.Errorf("failed to get user info, status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return core.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var userInfo core.User
|
||||||
|
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||||
|
return core.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return userInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAccessTokenFromRequest(c *gin.Context) (oauth2.Token, error) {
|
||||||
|
token, err := c.Cookie(nameHTTPCookie)
|
||||||
|
if err != nil {
|
||||||
|
return oauth2.Token{}, fmt.Errorf("cookie '%s' not found", nameHTTPCookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := repository.GetAccessToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return oauth2.Token{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserForAccessToken(accessToken oauth2.Token) (core.User, error) {
|
||||||
|
uuid, err := repository.GetAccessTokenToUserMapping(accessToken.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
repository.DeleteAccessToken(accessToken.AccessToken)
|
||||||
|
return core.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := repository.GetUser(uuid)
|
||||||
|
if err != nil {
|
||||||
|
repository.DeleteAccessTokenToUserMapping(accessToken.AccessToken)
|
||||||
|
repository.DeleteAccessToken(accessToken.AccessToken)
|
||||||
|
return core.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func introspect(token string) (introspectionResponse, error) {
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("token", token)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", urlKeycloakIOntrospect, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return introspectionResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBasicAuth(oAuthConfig.ClientID, oAuthConfig.ClientSecret)
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return introspectionResponse{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return introspectionResponse{}, fmt.Errorf("introspection failed with status: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var introspectResponse introspectionResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&introspectResponse); err != nil {
|
||||||
|
return introspectionResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return introspectResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProtectedURL(url string) bool {
|
||||||
|
_, isProtected := protectedURLs[url]
|
||||||
|
return isProtected
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRouteWithoutAuth(c *gin.Context) bool {
|
||||||
|
if c.FullPath() == GetURLCallback() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.FullPath() == GetURLLogin() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func logoutFromKeycloak(refreshToken string) error {
|
||||||
|
data := url.Values{}
|
||||||
|
data.Set("client_id", oAuthConfig.ClientID)
|
||||||
|
data.Set("client_secret", oAuthConfig.ClientSecret)
|
||||||
|
data.Set("refresh_token", refreshToken)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", urlKeycloakLogout, strings.NewReader(data.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error when creating the logout request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error when sending the logout request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
return fmt.Errorf("logout failed, Status: %d, Body: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func protectURLIfNecessary(c *gin.Context) {
|
||||||
|
accessToken := c.MustGet("accessToken").(oauth2.Token)
|
||||||
|
user := c.MustGet("user").(core.User)
|
||||||
|
|
||||||
|
if !isProtectedURL(c.FullPath()) {
|
||||||
|
// URL is not protected
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
introspectionResponse, err := introspect(accessToken.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
// failed to introspect
|
||||||
|
repository.DeleteAccessToken(accessToken.AccessToken)
|
||||||
|
repository.DeleteAccessTokenToUserMapping(accessToken.AccessToken)
|
||||||
|
deleteCookieAndRedirectToLogin(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !introspectionResponse.Active {
|
||||||
|
// session was revoked
|
||||||
|
repository.DeleteAccessToken(accessToken.AccessToken)
|
||||||
|
repository.DeleteAccessTokenToUserMapping(accessToken.AccessToken)
|
||||||
|
deleteCookieAndRedirectToLogin(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if introspectionResponse.DoesUserHasNecessaryGroupMembership() {
|
||||||
|
// user is golden
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// user has lost its group membership
|
||||||
|
logoutFromKeycloak(accessToken.RefreshToken)
|
||||||
|
repository.DeleteAccessToken(accessToken.AccessToken)
|
||||||
|
repository.DeleteAccessTokenToUserMapping(accessToken.AccessToken)
|
||||||
|
repository.DeleteUser(user.UUID)
|
||||||
|
deleteCookieAndRedirectToLogin(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshTokenIfNecessary(c *gin.Context) error {
|
||||||
|
accessToken := c.MustGet("accessToken").(oauth2.Token)
|
||||||
|
user := c.MustGet("user").(core.User)
|
||||||
|
|
||||||
|
if time.Until(accessToken.Expiry).Seconds() > minTokenValiditySeconds {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenSource := oAuthConfig.TokenSource(
|
||||||
|
context.Background(),
|
||||||
|
&oauth2.Token{
|
||||||
|
RefreshToken: accessToken.RefreshToken,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
newToken, err := tokenSource.Token()
|
||||||
|
if err != nil {
|
||||||
|
logoutFromKeycloak(accessToken.RefreshToken)
|
||||||
|
repository.DeleteAccessToken(accessToken.AccessToken)
|
||||||
|
repository.DeleteAccessTokenToUserMapping(accessToken.AccessToken)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = repository.SetAccessToken(*newToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = repository.SetAccessTokenToUserMapping(newToken.AccessToken, user.UUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.DeleteAccessToken(accessToken.AccessToken)
|
||||||
|
repository.DeleteAccessTokenToUserMapping(accessToken.AccessToken)
|
||||||
|
|
||||||
|
c.Set("accessToken", *newToken)
|
||||||
|
|
||||||
|
expiresIn := int(time.Until(accessToken.Expiry).Seconds())
|
||||||
|
c.SetCookie(nameHTTPCookie, accessToken.AccessToken, expiresIn, "/", "", false, true)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeCallback(c *gin.Context) {
|
||||||
|
code := c.Query("code")
|
||||||
|
if code == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Authorization code missing"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := oAuthConfig.Exchange(context.Background(), code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchedUser, err := fetchUserFromKeycloak(token.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch user info", "err": err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fetchedUser.HasGroupMembership(accessGroupNeededForThisApp) {
|
||||||
|
repository.DeleteUser(fetchedUser.UUID)
|
||||||
|
|
||||||
|
repository.DeleteAccessTokenToUserMapping(token.AccessToken)
|
||||||
|
repository.DeleteAccessToken(token.AccessToken)
|
||||||
|
|
||||||
|
c.SetCookie(nameHTTPCookie, "", -1, "/", "", false, true)
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "403 Forbidden"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.SetUser(fetchedUser)
|
||||||
|
repository.SetAccessToken(*token)
|
||||||
|
repository.SetAccessTokenToUserMapping(token.AccessToken, fetchedUser.UUID)
|
||||||
|
|
||||||
|
expiresIn := int(time.Until(token.Expiry).Seconds())
|
||||||
|
c.SetCookie(nameHTTPCookie, token.AccessToken, expiresIn, "/", "", false, true)
|
||||||
|
|
||||||
|
c.Redirect(http.StatusSeeOther, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeLogin(c *gin.Context) {
|
||||||
|
c.Redirect(
|
||||||
|
http.StatusSeeOther,
|
||||||
|
oAuthConfig.AuthCodeURL(
|
||||||
|
"random-state-string",
|
||||||
|
oauth2.AccessTypeOffline,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeLogout(c *gin.Context) {
|
||||||
|
accessToken := c.MustGet("accessToken").(oauth2.Token)
|
||||||
|
logoutFromKeycloak(accessToken.RefreshToken)
|
||||||
|
c.SetCookie(nameHTTPCookie, "", -1, "/", "", false, true)
|
||||||
|
c.Redirect(http.StatusSeeOther, urlAppLogin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r introspectionResponse) DoesUserHasNecessaryGroupMembership() bool {
|
||||||
|
for _, g := range r.Groups {
|
||||||
|
if g == accessGroupNeededForThisApp {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
69
repository/repository.go
Normal file
69
repository/repository.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.0x0001f346.de/andreas/gin-oauth2-demo/core"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var users map[string]core.User = map[string]core.User{}
|
||||||
|
var accessTokens map[string]oauth2.Token = map[string]oauth2.Token{}
|
||||||
|
var accessTokenToUserMapping map[string]string = map[string]string{}
|
||||||
|
|
||||||
|
func DeleteAccessToken(s string) {
|
||||||
|
delete(accessTokens, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteAccessTokenToUserMapping(s string) {
|
||||||
|
delete(accessTokenToUserMapping, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteUser(uuid string) {
|
||||||
|
delete(users, uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAccessToken(s string) (oauth2.Token, error) {
|
||||||
|
accessToken, accessTokenExists := accessTokens[s]
|
||||||
|
if !accessTokenExists {
|
||||||
|
return oauth2.Token{}, fmt.Errorf("AccessToken not in repository: %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAccessTokenToUserMapping(s string) (string, error) {
|
||||||
|
uuid, accessTokenToUserMappingExists := accessTokenToUserMapping[s]
|
||||||
|
if !accessTokenToUserMappingExists {
|
||||||
|
return "", fmt.Errorf("no user mapped to AccessToken: %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUser(uuid string) (core.User, error) {
|
||||||
|
user, userExists := users[uuid]
|
||||||
|
if !userExists {
|
||||||
|
return core.User{}, fmt.Errorf("user not in repository: %s", uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetAccessToken(token oauth2.Token) error {
|
||||||
|
accessTokens[token.AccessToken] = token
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetAccessTokenToUserMapping(token string, uuid string) error {
|
||||||
|
accessTokenToUserMapping[token] = uuid
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetUser(user core.User) error {
|
||||||
|
users[user.UUID] = user
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user