feat(oauth): add keycloak handler

pull/1118/head
Diane LAKESTANI 2025-01-29 20:14:13 +01:00
parent ad39c6523d
commit 6dbfea4d2c
13 changed files with 280 additions and 2303 deletions

View File

@ -3,7 +3,7 @@ COMMIT=$(shell git rev-parse HEAD)
SIGN_KEY=B76D61FAA6DB759466E83D9964B9C6AAE2D55278
BINARY_NAME=statping
GOBUILD=go build -a
GOVERSION=1.17.8
GOVERSION=1.19.1
NODE_VERSION=16.14.0
XGO=xgo -go $(GOVERSION) --dest=build
BUILDVERSION=-ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}"

View File

@ -8,7 +8,7 @@ const tokenKey = "statping_auth";
class Api {
constructor() {
this.version = "0.91.0";
this.commit = "b7ecf0c31b0c75c394061d2f6457a925e4440f1e";
this.commit = "ad39c6523da055360f71f879a6b8824743484d92";
}
async oauth() {

View File

@ -37,6 +37,10 @@
<font-awesome-icon :icon="['fab', 'google']" /> Login with Google
</a>
<a v-if="oauth && oauth.keycloak_client_id" @click.prevent="Keycloaklogin" href="#" class="btn btn-block btn-outline-dark">
<font-awesome-icon :icon="['fas', 'address-card']" /> Login with {{oauth.keycloak_name}}
</a>
<a v-if="oauth && oauth.custom_client_id" @click.prevent="Customlogin" href="#" class="btn btn-block btn-outline-dark">
<font-awesome-icon :icon="['fas', 'address-card']" /> Login with {{oauth.custom_name}}
</a>
@ -108,6 +112,17 @@
return "&scope="+scopes.join(" ")
}
return ""
},
keycloak_scopes() {
let scopes = []
if (this.oauth.keycloak_open_id) {
scopes.push("openid")
}
scopes.push(this.oauth.keycloak_scopes.split(","))
if (scopes.length !== 0) {
return "&scope="+scopes.join(" ")
}
return ""
},
GHlogin() {
window.location = `https://github.com/login/oauth/authorize?client_id=${this.oauth.gh_client_id}&redirect_uri=${this.encode(this.core.domain+"/oauth/github")}&scope=read:user,read:org`
@ -120,6 +135,9 @@
},
Customlogin() {
window.location = `${this.oauth.custom_endpoint_auth}?client_id=${this.oauth.custom_client_id}&redirect_uri=${this.encode(this.core.domain+"/oauth/custom")}&response_type=code${this.custom_scopes()}`
},
Keycloaklogin() {
window.location = `${this.oauth.keycloak_endpoint_auth}?client_id=${this.oauth.keycloak_client_id}&redirect_uri=${this.encode(this.core.domain+"/oauth/keycloak")}&response_type=code${this.keycloak_scopes()}`
}
}
}

View File

@ -159,6 +159,88 @@
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<font-awesome-icon @click="expanded.keycloak = !expanded.keycloak" :icon="expanded.keycloak ? 'minus' : 'plus'" class="mr-2 pointer"/>
Keycloak Settings
<span @click="keycloak_enabled = !!keycloak_enabled" class="switch switch-sm switch-rd-gr float-right">
<input v-model="keycloak_enabled" type="checkbox" id="switch-keycloak-oauth" :checked="keycloak_enabled">
<label for="switch-keycloak-oauth" class="mb-0"> </label>
</span>
</div>
<div class="form-group row">
<label for="keycloak_client_id" class="col-sm-4 col-form-label">Keycloak Client ID</label>
<div class="col-sm-8">
<input v-model="oauth.keycloak_client_id" type="text" class="form-control" id="keycloak_client_id" required>
</div>
</div>
<div class="form-group row">
<label for="keycloak_client_secret" class="col-sm-4 col-form-label">Keycloak Client Secret</label>
<div class="col-sm-8">
<input v-model="oauth.keycloak_client_secret" type="text" class="form-control" id="keycloak_client_secret" required>
</div>
</div>
<div class="form-group row">
<label for="keycloak_auth_url" class="col-sm-4 col-form-label">Keycloak Auth URL</label>
<div class="col-sm-8">
<input v-model="oauth.keycloak_auth_url" type="text" class="form-control" id="keycloak_auth_url" required>
</div>
</div>
<div class="form-group row">
<label for="keycloak_token_url" class="col-sm-4 col-form-label">Keycloak Token URL</label>
<div class="col-sm-8">
<input v-model="oauth.keycloak_token_url" type="text" class="form-control" id="keycloak_token_url" required>
</div>
</div>
<div class="form-group row">
<label for="keycloak_user_info_url" class="col-sm-4 col-form-label">Keycloak User Info URL</label>
<div class="col-sm-8">
<input v-model="oauth.keycloak_user_info_url" type="text" class="form-control" id="keycloak_user_info_url" required>
</div>
</div>
<div class="form-group row">
<label for="keycloak_scopes" class="col-sm-4 col-form-label">Scopes</label>
<div class="col-sm-8">
<input v-model="oauth.keycloak_scopes" type="text" class="form-control" id="keycloak_scopes" placeholder="e.g. openid profile email">
</div>
</div>
<div class="form-group row">
<label for="keycloak_admin_groups" class="col-sm-4 col-form-label">Admin Groups</label>
<div class="col-sm-8">
<input v-model="oauth.keycloak_admin_groups" type="text" class="form-control" id="keycloak_admin_groups" placeholder="e.g. group1,group2,group3,group4">
</div>
</div>
<div class="form-group row">
<label for="keycloak_open_id" class="col-sm-4 col-form-label">Use OpenID</label>
<div class="col-sm-8">
<input v-model="oauth.keycloak_open_id" type="checkbox" class="form-check-input" id="keycloak_open_id">
</div>
</div>
<div class="form-group row">
<label for="switch-keycloak-open-id" class="col-sm-4 col-form-label">Open ID</label>
<div class="col-sm-8">
<span @click="oauth.keycloak_openid = !!oauth.keycloak_open_id" class="switch switch-rd-gr float-right">
<input v-model="oauth.keycloak_open_id" type="checkbox" id="switch-keycloak-open-id" :checked="oauth.keycloak_open_id">
<label for="switch-keycloak-open-id" class="mb-0"> </label>
</span>
<small>Enable if provider is OpenID</small>
</div>
</div>
<div class="form-group row">
<label for="slack_callback" class="col-sm-4 col-form-label">Callback URL</label>
<div class="col-sm-8">
<div class="input-group">
<input v-bind:value="`${core.domain}/oauth/keycloak`" type="text" class="form-control" id="keycloak_callback" readonly>
<div class="input-group-append copy-btn">
<button @click.prevent="copy(`${core.domain}/oauth/keycloak`)" class="btn btn-outline-secondary" type="button">Copy</button>
</div>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<font-awesome-icon @click="expanded.custom = !expanded.custom" :icon="expanded.custom ? 'minus' : 'plus'" class="mr-2 pointer"/>
@ -255,6 +337,7 @@
github_enabled: false,
local_enabled: false,
custom_enabled: false,
keycloak_enabled: false,
loading: false,
expanded: {
github: false,
@ -262,6 +345,7 @@
slack: false,
custom: false,
openid: false,
keycloak: false,
},
oauth: {
gh_client_id: "",
@ -283,6 +367,14 @@
custom_endpoint_token: "",
custom_scopes: "",
custom_open_id: false,
keycloak_client: "",
keycloak_client_secret: "",
keycloak_auth_url: "",
keycloak_token_url: "",
keycloak_user_info_url: "",
keycloak_scopes: '',
keycloak_admin_groups: '',
keycloak_open_id: false,
}
}
},
@ -293,6 +385,7 @@
this.google_enabled = this.has('google')
this.slack_enabled = this.has('slack')
this.custom_enabled = this.has('custom')
this.keycloak_enabled = this.has('keycloak')
},
methods: {
providers() {
@ -312,6 +405,9 @@
if (this.custom_enabled) {
providers.push("custom")
}
if (this.keycloak_enabled) {
providers.push("keycloak")
}
return providers.join(",")
},
has(val) {

File diff suppressed because it is too large Load Diff

View File

@ -9,12 +9,14 @@ import (
"github.com/statping-ng/statping-ng/types/users"
"golang.org/x/oauth2"
"net/http"
"strings"
)
type oAuth struct {
Email string
Username string
*oauth2.Token
Groups []string
}
func oauthHandler(w http.ResponseWriter, r *http.Request) {
@ -32,6 +34,8 @@ func oauthHandler(w http.ResponseWriter, r *http.Request) {
oauth, err = slackOAuth(r)
case "custom":
oauth, err = customOAuth(r)
case "keycloak":
oauth, err = keycloakOAuth(r)
default:
err = errors.New("unknown oauth provider")
}
@ -50,10 +54,30 @@ func oauthLogin(oauth *oAuth, w http.ResponseWriter, r *http.Request) {
Id: 0,
Username: oauth.Username,
Email: oauth.Email,
Admin: null.NewNullBool(true),
Admin: null.NewNullBool(false),
}
// Check if the user is in the Keycloak admin groups
if oauth.Groups != nil && core.App.OAuth.KeycloakAdminGroups != "" {
adminGroups := strings.Split(core.App.OAuth.KeycloakAdminGroups, ",")
for _, group := range adminGroups {
if contains(oauth.Groups, group) {
user.Admin = null.NewNullBool(true)
break
}
}
}
log.Infoln(fmt.Sprintf("OAuth %s User %s logged in from IP %s", oauth.Type(), oauth.Email, r.RemoteAddr))
setJwtToken(user, w)
http.Redirect(w, r, core.App.Domain+"/dashboard", http.StatusPermanentRedirect)
}
func contains(s []string, str string) bool {
for _, v := range s {
if v == str {
return true
}
}
return false
}

View File

@ -0,0 +1,59 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/statping-ng/statping-ng/types/core"
"golang.org/x/oauth2"
)
type keycloakUserInfo struct {
Username string `json:"preferred_username"`
Email string `json:"email"`
Groups []string `json:"groups"`
}
func keycloakOAuth(r *http.Request) (*oAuth, error) {
auth := core.App.OAuth
code := r.URL.Query().Get("code")
config := &oauth2.Config{
ClientID: auth.KeycloakClientID,
ClientSecret: auth.KeycloakClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: auth.KeycloakAuthURL,
TokenURL: auth.KeycloakTokenURL,
},
RedirectURL: core.App.Domain + basePath + "oauth/keycloak",
Scopes: strings.Split(auth.KeycloakScopes, ","),
}
token, err := config.Exchange(r.Context(), code)
if err != nil {
log.Errorln("Error exchanging token:", err)
return nil, err
}
client := config.Client(r.Context(), token)
userInfoResp, err := client.Get(auth.KeycloakUserInfoURL)
if err != nil {
log.Errorln("Error getting user info:", err)
return nil, err
}
defer userInfoResp.Body.Close()
var user keycloakUserInfo
if err := json.NewDecoder(userInfoResp.Body).Decode(&user); err != nil {
log.Errorln("Error decoding user info:", err)
return nil, err
}
return &oAuth{
Token: token,
Username: user.Username,
Email: user.Email,
Groups: user.Groups,
}, nil
}

View File

@ -26,6 +26,11 @@ func LoadConfigForm(r *http.Request) (*DbConfig, error) {
email := g("email")
language := g("language")
reports, _ := strconv.ParseBool(g("send_reports"))
keycloakClientID := g("keycloak_client_id")
keycloakClientSecret := g("keycloak_client_secret")
keycloakAuthURL := g("keycloak_auth_url")
keycloakTokenURL := g("keycloak_token_url")
keycloakUserInfoURL := g("keycloak_user_info_url")
if project == "" || username == "" || password == "" {
err := errors.New("Missing required elements on setup form")
@ -46,6 +51,11 @@ func LoadConfigForm(r *http.Request) (*DbConfig, error) {
p.Set("ADMIN_USER", username)
p.Set("ADMIN_PASSWORD", password)
p.Set("ADMIN_EMAIL", email)
p.Set("KEYCLOAK_CLIENT_ID", keycloakClientID)
p.Set("KEYCLOAK_CLIENT_SECRET", keycloakClientSecret)
p.Set("KEYCLOAK_AUTH_URL", keycloakAuthURL)
p.Set("KEYCLOAK_TOKEN_URL", keycloakTokenURL)
p.Set("KEYCLOAK_USER_INFO_URL", keycloakUserInfoURL)
confg := &DbConfig{
DbConn: dbConn,
@ -63,6 +73,11 @@ func LoadConfigForm(r *http.Request) (*DbConfig, error) {
Location: utils.Directory,
Language: language,
AllowReports: reports,
KeycloakClientID: keycloakClientID,
KeycloakClientSecret: keycloakClientSecret,
KeycloakAuthURL: keycloakAuthURL,
KeycloakTokenURL: keycloakTokenURL,
KeycloakUserInfoURL: keycloakUserInfoURL,
}
return confg, nil

View File

@ -34,6 +34,13 @@ func Save() error {
MaxOpenConnections: p.GetInt("MAX_OPEN_CONN"),
MaxIdleConnections: p.GetInt("MAX_IDLE_CONN"),
MaxLifeConnections: int(p.GetDuration("MAX_LIFE_CONN").Seconds()),
KeycloakClientID: p.GetString("KEYCLOAK_CLIENT_ID"),
KeycloakClientSecret: p.GetString("KEYCLOAK_CLIENT_SECRET"),
KeycloakAuthURL: p.GetString("KEYCLOAK_AUTH_URL"),
KeycloakTokenURL: p.GetString("KEYCLOAK_TOKEN_URL"),
KeycloakUserInfoURL: p.GetString("KEYCLOAK_USER_INFO_URL"),
KeycloakScopes: p.GetString("KEYCLOAK_SCOPES"),
KeycloakAdminGroups: p.GetString("KEYCLOAK_ADMIN_GROUPS"),
}
return configs.Save(utils.Directory)
}
@ -102,6 +109,27 @@ func LoadConfigs(cfgFile string) (*DbConfig, error) {
if db.LetsEncryptEnable {
p.Set("LETSENCRYPT_ENABLE", db.LetsEncryptEnable)
}
if db.KeycloakClientID != "" {
p.Set("KEYCLOAK_CLIENT_ID", db.KeycloakClientID)
}
if db.KeycloakClientSecret != "" {
p.Set("KEYCLOAK_CLIENT_SECRET", db.KeycloakClientSecret)
}
if db.KeycloakAuthURL != "" {
p.Set("KEYCLOAK_AUTH_URL", db.KeycloakAuthURL)
}
if db.KeycloakTokenURL != "" {
p.Set("KEYCLOAK_TOKEN_URL", db.KeycloakTokenURL)
}
if db.KeycloakUserInfoURL != "" {
p.Set("KEYCLOAK_USER_INFO_URL", db.KeycloakUserInfoURL)
}
if db.KeycloakScopes != "" {
p.Set("KEYCLOAK_SCOPES", db.KeycloakScopes)
}
if db.KeycloakAdminGroups != "" {
p.Set("KEYCLOAK_ADMIN_GROUPS", db.KeycloakAdminGroups)
}
configs := &DbConfig{
DbConn: p.GetString("DB_CONN"),
@ -125,6 +153,13 @@ func LoadConfigs(cfgFile string) (*DbConfig, error) {
LetsEncryptEmail: p.GetString("LETSENCRYPT_EMAIL"),
ApiSecret: p.GetString("API_SECRET"),
SampleData: p.GetBool("SAMPLE_DATA"),
KeycloakClientID: p.GetString("KEYCLOAK_CLIENT_ID"),
KeycloakClientSecret: p.GetString("KEYCLOAK_CLIENT_SECRET"),
KeycloakAuthURL: p.GetString("KEYCLOAK_AUTH_URL"),
KeycloakTokenURL: p.GetString("KEYCLOAK_TOKEN_URL"),
KeycloakUserInfoURL: p.GetString("KEYCLOAK_USER_INFO_URL"),
KeycloakScopes: p.GetString("KEYCLOAK_SCOPES"),
KeycloakAdminGroups: p.GetString("KEYCLOAK_ADMIN_GROUPS"),
}
log.WithFields(utils.ToFields(configs)).Debugln("read config file: " + cfgFile)

View File

@ -49,5 +49,14 @@ type DbConfig struct {
PostgresSSLMode string `yaml:"postgres_ssl,omitempty" json:"postgres_ssl"`
KeycloakClientID string `yaml:"keycloak_client_id,omitempty" json:"keycloak_client_id"`
KeycloakClientSecret string `yaml:"keycloak_client_secret,omitempty" json:"keycloak_client_secret"`
KeycloakAuthURL string `yaml:"keycloak_auth_url,omitempty" json:"keycloak_auth_url"`
KeycloakTokenURL string `yaml:"keycloak_token_url,omitempty" json:"keycloak_token_url"`
KeycloakUserInfoURL string `yaml:"keycloak_user_info_url,omitempty" json:"keycloak_user_info_url"`
KeycloakScopes string `yaml:"keycloak_scopes,omitempty" json:"keycloak_scopes"`
KeycloakAdminGroups string `yaml:"keycloak_admin_groups,omitempty" json:"keycloak_admin_groups"`
KeycloakIsOpenID bool `yaml:"keycloak_open_id,omitempty" json:"keycloak_open_id"`
Db database.Database `yaml:"-" json:"-"`
}

View File

@ -64,6 +64,14 @@ type OAuth struct {
CustomEndpointToken string `gorm:"column:custom_endpoint_token" json:"custom_endpoint_token" scope:"admin"`
CustomScopes string `gorm:"column:custom_scopes" json:"custom_scopes"`
CustomIsOpenID null.NullBool `gorm:"column:custom_open_id" json:"custom_open_id"`
KeycloakClientID string `gorm:"column:keycloak_client_id" json:"keycloak_client_id"`
KeycloakClientSecret string `gorm:"column:keycloak_client_secret" json:"keycloak_client_secret" scope:"admin"`
KeycloakAuthURL string `gorm:"column:keycloak_auth_url" json:"keycloak_auth_url"`
KeycloakTokenURL string `gorm:"column:keycloak_token_url" json:"keycloak_token_url"`
KeycloakUserInfoURL string `gorm:"column:keycloak_user_info_url" json:"keycloak_user_info_url"`
KeycloakScopes string `gorm:"column:keycloak_scopes" json:"keycloak_scopes"`
KeycloakAdminGroups string `gorm:"column:keycloak_admin_groups" json:"keycloak_admin_groups"`
KeycloakIsOpenID null.NullBool `gorm:"column:keycloak_open_id" json:"keycloak_open_id"`
}
// AllNotifiers contains all the Notifiers loaded

View File

@ -14,6 +14,7 @@ type User struct {
ApiKey string `gorm:"column:api_key" json:"api_key,omitempty"`
Scopes string `gorm:"column:scopes" json:"scopes,omitempty"`
Admin null.NullBool `gorm:"column:administrator" json:"admin,omitempty"`
Groups []string `gorm:"-" json:"groups,omitempty"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
Token string `gorm:"-" json:"token"`

View File

@ -59,6 +59,15 @@ func InitEnvs() {
Params.SetDefault("LOGS_MAX_AGE", 28)
Params.SetDefault("LOGS_MAX_SIZE", 16)
Params.SetDefault("DISABLE_COLORS", false)
Params.SetDefault("KEYCLOAK_CLIENT_ID", "")
Params.SetDefault("KEYCLOAK_CLIENT_SECRET", "")
Params.SetDefault("KEYCLOAK_AUTH_URL", "")
Params.SetDefault("KEYCLOAK_TOKEN_URL", "")
Params.SetDefault("KEYCLOAK_USER_INFO_URL", "")
Params.SetDefault("KEYCLOAK_SCOPES", "openid profile email")
Params.SetDefault("KEYCLOAK_ADMIN_GROUPS", "")
Params.SetDefault("KEYCLOAK_OPENID", false)
Params.SetDefault("KEYCLOAK_CALLBACK_URL", "")
dbConn := Params.GetString("DB_CONN")
dbInt := Params.GetInt("DB_PORT")