mirror of
https://github.com/h44z/wg-portal.git
synced 2025-09-13 06:21:15 +00:00
V2 alpha - initial version (#172)
Initial alpha codebase for version 2 of WireGuard Portal. This version is considered unstable and incomplete (for example, no public REST API)! Use with care! Fixes/Implements the following issues: - OAuth support #154, #1 - New Web UI with internationalisation support #98, #107, #89, #62 - Postgres Support #49 - Improved Email handling #47, #119 - DNS Search Domain support #46 - Bugfixes #94, #48 --------- Co-authored-by: Fabian Wechselberger <wechselbergerf@hotmail.com>
This commit is contained in:
386
internal/app/auth/auth.go
Normal file
386
internal/app/auth/auth.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/h44z/wg-portal/internal/app"
|
||||
"github.com/sirupsen/logrus"
|
||||
"io"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
evbus "github.com/vardius/message-bus"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
type UserManager interface {
|
||||
GetUser(context.Context, domain.UserIdentifier) (*domain.User, error)
|
||||
RegisterUser(ctx context.Context, user *domain.User) error
|
||||
}
|
||||
|
||||
type Authenticator struct {
|
||||
cfg *config.Auth
|
||||
bus evbus.MessageBus
|
||||
|
||||
oauthAuthenticators map[string]domain.OauthAuthenticator
|
||||
ldapAuthenticators map[string]domain.LdapAuthenticator
|
||||
|
||||
users UserManager
|
||||
}
|
||||
|
||||
func NewAuthenticator(cfg *config.Auth, bus evbus.MessageBus, users UserManager) (*Authenticator, error) {
|
||||
a := &Authenticator{
|
||||
cfg: cfg,
|
||||
bus: bus,
|
||||
users: users,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := a.setupExternalAuthProviders(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) setupExternalAuthProviders(ctx context.Context) error {
|
||||
extUrl, err := url.Parse(a.cfg.CallbackUrlPrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse external url: %w", err)
|
||||
}
|
||||
|
||||
a.oauthAuthenticators = make(map[string]domain.OauthAuthenticator, len(a.cfg.OpenIDConnect)+len(a.cfg.OAuth))
|
||||
a.ldapAuthenticators = make(map[string]domain.LdapAuthenticator, len(a.cfg.Ldap))
|
||||
|
||||
for i := range a.cfg.OpenIDConnect { // OIDC
|
||||
providerCfg := &a.cfg.OpenIDConnect[i]
|
||||
providerId := strings.ToLower(providerCfg.ProviderName)
|
||||
|
||||
if _, exists := a.oauthAuthenticators[providerId]; exists {
|
||||
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
|
||||
}
|
||||
|
||||
redirectUrl := *extUrl
|
||||
redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
|
||||
|
||||
provider, err := newOidcAuthenticator(ctx, redirectUrl.String(), providerCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup oidc authentication provider %s: %w", providerCfg.ProviderName, err)
|
||||
}
|
||||
a.oauthAuthenticators[providerId] = provider
|
||||
}
|
||||
for i := range a.cfg.OAuth { // PLAIN OAUTH
|
||||
providerCfg := &a.cfg.OAuth[i]
|
||||
providerId := strings.ToLower(providerCfg.ProviderName)
|
||||
|
||||
if _, exists := a.oauthAuthenticators[providerId]; exists {
|
||||
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
|
||||
}
|
||||
|
||||
redirectUrl := *extUrl
|
||||
redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback")
|
||||
|
||||
provider, err := newPlainOauthAuthenticator(ctx, redirectUrl.String(), providerCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup oauth authentication provider %s: %w", providerId, err)
|
||||
}
|
||||
a.oauthAuthenticators[providerId] = provider
|
||||
}
|
||||
for i := range a.cfg.Ldap { // LDAP
|
||||
providerCfg := &a.cfg.Ldap[i]
|
||||
providerId := strings.ToLower(providerCfg.URL)
|
||||
|
||||
if _, exists := a.ldapAuthenticators[providerId]; exists {
|
||||
return fmt.Errorf("auth provider with name %s is already registerd", providerId)
|
||||
}
|
||||
|
||||
provider, err := newLdapAuthenticator(ctx, providerCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup ldap authentication provider %s: %w", providerId, err)
|
||||
}
|
||||
a.ldapAuthenticators[providerId] = provider
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) GetExternalLoginProviders(_ context.Context) []domain.LoginProviderInfo {
|
||||
authProviders := make([]domain.LoginProviderInfo, 0, len(a.cfg.OAuth)+len(a.cfg.OpenIDConnect))
|
||||
|
||||
for _, provider := range a.cfg.OpenIDConnect {
|
||||
providerId := strings.ToLower(provider.ProviderName)
|
||||
providerName := provider.DisplayName
|
||||
if providerName == "" {
|
||||
providerName = provider.ProviderName
|
||||
}
|
||||
authProviders = append(authProviders, domain.LoginProviderInfo{
|
||||
Identifier: providerId,
|
||||
Name: providerName,
|
||||
ProviderUrl: fmt.Sprintf("/auth/login/%s/init", providerId),
|
||||
CallbackUrl: fmt.Sprintf("/auth/login/%s/callback", providerId),
|
||||
})
|
||||
}
|
||||
|
||||
for _, provider := range a.cfg.OAuth {
|
||||
providerId := strings.ToLower(provider.ProviderName)
|
||||
providerName := provider.DisplayName
|
||||
if providerName == "" {
|
||||
providerName = provider.ProviderName
|
||||
}
|
||||
authProviders = append(authProviders, domain.LoginProviderInfo{
|
||||
Identifier: providerId,
|
||||
Name: providerName,
|
||||
ProviderUrl: fmt.Sprintf("%s/%s/init", a.cfg.CallbackUrlPrefix, providerId),
|
||||
CallbackUrl: fmt.Sprintf("%s/%s/callback", a.cfg.CallbackUrlPrefix, providerId),
|
||||
})
|
||||
}
|
||||
|
||||
return authProviders
|
||||
}
|
||||
|
||||
func (a *Authenticator) IsUserValid(ctx context.Context, id domain.UserIdentifier) bool {
|
||||
user, err := a.users.GetUser(ctx, id)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if user.IsDisabled() {
|
||||
return false
|
||||
}
|
||||
|
||||
if user.IsLocked() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// region password authentication
|
||||
|
||||
func (a *Authenticator) PlainLogin(ctx context.Context, username, password string) (*domain.User, error) {
|
||||
// Validate form input
|
||||
username = strings.TrimSpace(username)
|
||||
password = strings.TrimSpace(password)
|
||||
if username == "" || password == "" {
|
||||
return nil, fmt.Errorf("missing username or password")
|
||||
}
|
||||
|
||||
user, err := a.passwordAuthentication(ctx, domain.UserIdentifier(username), password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
a.bus.Publish(app.TopicAuthLogin, user.Identifier)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) passwordAuthentication(ctx context.Context, identifier domain.UserIdentifier, password string) (*domain.User, error) {
|
||||
var ldapUserInfo *domain.AuthenticatorUserInfo
|
||||
var ldapProvider domain.LdapAuthenticator
|
||||
|
||||
var userInDatabase = false
|
||||
var userSource domain.UserSource
|
||||
existingUser, err := a.users.GetUser(ctx, identifier)
|
||||
if err == nil {
|
||||
userInDatabase = true
|
||||
userSource = existingUser.Source
|
||||
}
|
||||
if userInDatabase && (existingUser.IsLocked() || existingUser.IsDisabled()) {
|
||||
return nil, errors.New("user is locked")
|
||||
}
|
||||
|
||||
if !userInDatabase || userSource == domain.UserSourceLdap {
|
||||
// search user in ldap if registration is enabled
|
||||
for _, ldapAuth := range a.ldapAuthenticators {
|
||||
if !userInDatabase && !ldapAuth.RegistrationEnabled() {
|
||||
continue
|
||||
}
|
||||
|
||||
rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
|
||||
if err != nil {
|
||||
if !errors.Is(err, domain.ErrNotFound) {
|
||||
logrus.Warnf("failed to fetch ldap user info for %s: %v", identifier, err)
|
||||
}
|
||||
continue // user not found / other ldap error
|
||||
}
|
||||
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// ldap user found
|
||||
userSource = domain.UserSourceLdap
|
||||
ldapProvider = ldapAuth
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if userSource == "" {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
switch userSource {
|
||||
case domain.UserSourceDatabase:
|
||||
err = existingUser.CheckPassword(password)
|
||||
case domain.UserSourceLdap:
|
||||
err = ldapProvider.PlaintextAuthentication(identifier, password)
|
||||
default:
|
||||
err = errors.New("no authentication backend available")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to authenticate: %w", err)
|
||||
}
|
||||
|
||||
if !userInDatabase {
|
||||
user, err := a.processUserInfo(ctx, ldapUserInfo, domain.UserSourceLdap, ldapProvider.GetName(), ldapProvider.RegistrationEnabled())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to process user information: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
} else {
|
||||
return existingUser, nil
|
||||
}
|
||||
}
|
||||
|
||||
// endregion password authentication
|
||||
|
||||
// region oauth authentication
|
||||
|
||||
func (a *Authenticator) OauthLoginStep1(_ context.Context, providerId string) (authCodeUrl, state, nonce string, err error) {
|
||||
oauthProvider, ok := a.oauthAuthenticators[providerId]
|
||||
if !ok {
|
||||
return "", "", "", fmt.Errorf("missing oauth provider %s", providerId)
|
||||
}
|
||||
|
||||
// Prepare authentication flow, set state cookies
|
||||
state, err = a.randString(16)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to generate state: %w", err)
|
||||
}
|
||||
|
||||
switch oauthProvider.GetType() {
|
||||
case domain.AuthenticatorTypeOAuth:
|
||||
authCodeUrl = oauthProvider.AuthCodeURL(state)
|
||||
case domain.AuthenticatorTypeOidc:
|
||||
nonce, err = a.randString(16)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
authCodeUrl = oauthProvider.AuthCodeURL(state, oidc.Nonce(nonce))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (a *Authenticator) randString(nByte int) (string, error) {
|
||||
b := make([]byte, nByte)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) OauthLoginStep2(ctx context.Context, providerId, nonce, code string) (*domain.User, error) {
|
||||
oauthProvider, ok := a.oauthAuthenticators[providerId]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing oauth provider %s", providerId)
|
||||
}
|
||||
|
||||
oauth2Token, err := oauthProvider.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to exchange code: %w", err)
|
||||
}
|
||||
|
||||
rawUserInfo, err := oauthProvider.GetUserInfo(ctx, oauth2Token, nonce)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to fetch user information: %w", err)
|
||||
}
|
||||
|
||||
userInfo, err := oauthProvider.ParseUserInfo(rawUserInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse user information: %w", err)
|
||||
}
|
||||
|
||||
user, err := a.processUserInfo(ctx, userInfo, domain.UserSourceOauth, oauthProvider.GetName(), oauthProvider.RegistrationEnabled())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to process user information: %w", err)
|
||||
}
|
||||
|
||||
if user.IsLocked() || user.IsDisabled() {
|
||||
return nil, errors.New("user is locked")
|
||||
}
|
||||
|
||||
a.bus.Publish(app.TopicAuthLogin, user.Identifier)
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) processUserInfo(ctx context.Context, userInfo *domain.AuthenticatorUserInfo, source domain.UserSource, provider string, withReg bool) (*domain.User, error) {
|
||||
// Search user in backend
|
||||
user, err := a.users.GetUser(ctx, userInfo.Identifier)
|
||||
switch {
|
||||
case err != nil && withReg:
|
||||
user, err = a.registerNewUser(ctx, userInfo, source, provider)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register user: %w", err)
|
||||
}
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("registration disabled, cannot create missing user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) registerNewUser(ctx context.Context, userInfo *domain.AuthenticatorUserInfo, source domain.UserSource, provider string) (*domain.User, error) {
|
||||
// convert user info to domain.User
|
||||
user := &domain.User{
|
||||
Identifier: userInfo.Identifier,
|
||||
Email: userInfo.Email,
|
||||
Source: source,
|
||||
ProviderName: provider,
|
||||
IsAdmin: userInfo.IsAdmin,
|
||||
Firstname: userInfo.Firstname,
|
||||
Lastname: userInfo.Lastname,
|
||||
Phone: userInfo.Phone,
|
||||
Department: userInfo.Department,
|
||||
}
|
||||
|
||||
err := a.users.RegisterUser(ctx, user)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register new user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) getAuthenticatorConfig(id string) (interface{}, error) {
|
||||
for i := range a.cfg.OpenIDConnect {
|
||||
if a.cfg.OpenIDConnect[i].ProviderName == id {
|
||||
return a.cfg.OpenIDConnect[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
for i := range a.cfg.OAuth {
|
||||
if a.cfg.OAuth[i].ProviderName == id {
|
||||
return a.cfg.OAuth[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no configuration for Authenticator id %s", id)
|
||||
}
|
||||
|
||||
// endregion oauth authentication
|
168
internal/app/auth/auth_ldap.go
Normal file
168
internal/app/auth/auth_ldap.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
type LdapAuthenticator struct {
|
||||
cfg *config.LdapProvider
|
||||
}
|
||||
|
||||
func newLdapAuthenticator(_ context.Context, cfg *config.LdapProvider) (*LdapAuthenticator, error) {
|
||||
var provider = &LdapAuthenticator{}
|
||||
|
||||
provider.cfg = cfg
|
||||
|
||||
dn, err := ldap.ParseDN(cfg.AdminGroupDN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse admin group DN: %w", err)
|
||||
}
|
||||
provider.cfg.FieldMap = provider.getLdapFieldMapping(cfg.FieldMap)
|
||||
provider.cfg.ParsedAdminGroupDN = dn
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (l LdapAuthenticator) GetName() string {
|
||||
return l.cfg.ProviderName
|
||||
}
|
||||
|
||||
func (l LdapAuthenticator) RegistrationEnabled() bool {
|
||||
return l.cfg.RegistrationEnabled
|
||||
}
|
||||
|
||||
func (l LdapAuthenticator) PlaintextAuthentication(userId domain.UserIdentifier, plainPassword string) error {
|
||||
conn, err := internal.LdapConnect(l.cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup connection: %w", err)
|
||||
}
|
||||
defer internal.LdapDisconnect(conn)
|
||||
|
||||
attrs := []string{"dn"}
|
||||
|
||||
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1)
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
l.cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
|
||||
loginFilter, attrs, nil,
|
||||
)
|
||||
|
||||
sr, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to search in ldap: %w", err)
|
||||
}
|
||||
|
||||
if len(sr.Entries) == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
|
||||
if len(sr.Entries) > 1 {
|
||||
return domain.ErrNotUnique
|
||||
}
|
||||
|
||||
// Bind as the user to verify their password
|
||||
userDN := sr.Entries[0].DN
|
||||
err = conn.Bind(userDN, plainPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid credentials: %w", err)
|
||||
}
|
||||
_ = conn.Unbind()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l LdapAuthenticator) GetUserInfo(_ context.Context, userId domain.UserIdentifier) (map[string]interface{}, error) {
|
||||
conn, err := internal.LdapConnect(l.cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to setup connection: %w", err)
|
||||
}
|
||||
defer internal.LdapDisconnect(conn)
|
||||
|
||||
attrs := internal.LdapSearchAttributes(&l.cfg.FieldMap)
|
||||
|
||||
loginFilter := strings.Replace(l.cfg.LoginFilter, "{{login_identifier}}", string(userId), -1)
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
l.cfg.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 20, false, // 20 second time limit
|
||||
loginFilter, attrs, nil,
|
||||
)
|
||||
|
||||
sr, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to search in ldap: %w", err)
|
||||
}
|
||||
|
||||
if len(sr.Entries) == 0 {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
|
||||
if len(sr.Entries) > 1 {
|
||||
return nil, domain.ErrNotUnique
|
||||
}
|
||||
|
||||
users := internal.LdapConvertEntries(sr, &l.cfg.FieldMap)
|
||||
|
||||
return users[0], nil
|
||||
}
|
||||
|
||||
func (l LdapAuthenticator) ParseUserInfo(raw map[string]interface{}) (*domain.AuthenticatorUserInfo, error) {
|
||||
isAdmin, err := internal.LdapIsMemberOf(raw[l.cfg.FieldMap.GroupMembership].([][]byte), l.cfg.ParsedAdminGroupDN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check admin group: %w", err)
|
||||
}
|
||||
userInfo := &domain.AuthenticatorUserInfo{
|
||||
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, l.cfg.FieldMap.UserIdentifier, "")),
|
||||
Email: internal.MapDefaultString(raw, l.cfg.FieldMap.Email, ""),
|
||||
Firstname: internal.MapDefaultString(raw, l.cfg.FieldMap.Firstname, ""),
|
||||
Lastname: internal.MapDefaultString(raw, l.cfg.FieldMap.Lastname, ""),
|
||||
Phone: internal.MapDefaultString(raw, l.cfg.FieldMap.Phone, ""),
|
||||
Department: internal.MapDefaultString(raw, l.cfg.FieldMap.Department, ""),
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
func (l LdapAuthenticator) getLdapFieldMapping(f config.LdapFields) config.LdapFields {
|
||||
defaultMap := config.LdapFields{
|
||||
BaseFields: config.BaseFields{
|
||||
UserIdentifier: "mail",
|
||||
Email: "mail",
|
||||
Firstname: "givenName",
|
||||
Lastname: "sn",
|
||||
Phone: "telephoneNumber",
|
||||
Department: "department",
|
||||
},
|
||||
GroupMembership: "memberOf",
|
||||
}
|
||||
if f.UserIdentifier != "" {
|
||||
defaultMap.UserIdentifier = f.UserIdentifier
|
||||
}
|
||||
if f.Email != "" {
|
||||
defaultMap.Email = f.Email
|
||||
}
|
||||
if f.Firstname != "" {
|
||||
defaultMap.Firstname = f.Firstname
|
||||
}
|
||||
if f.Lastname != "" {
|
||||
defaultMap.Lastname = f.Lastname
|
||||
}
|
||||
if f.Phone != "" {
|
||||
defaultMap.Phone = f.Phone
|
||||
}
|
||||
if f.Department != "" {
|
||||
defaultMap.Department = f.Department
|
||||
}
|
||||
if f.GroupMembership != "" {
|
||||
defaultMap.GroupMembership = f.GroupMembership
|
||||
}
|
||||
|
||||
return defaultMap
|
||||
}
|
149
internal/app/auth/auth_oauth.go
Normal file
149
internal/app/auth/auth_oauth.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type PlainOauthAuthenticator struct {
|
||||
name string
|
||||
cfg *oauth2.Config
|
||||
userInfoEndpoint string
|
||||
client *http.Client
|
||||
userInfoMapping config.OauthFields
|
||||
registrationEnabled bool
|
||||
}
|
||||
|
||||
func newPlainOauthAuthenticator(_ context.Context, callbackUrl string, cfg *config.OAuthProvider) (*PlainOauthAuthenticator, error) {
|
||||
var provider = &PlainOauthAuthenticator{}
|
||||
|
||||
provider.name = cfg.ProviderName
|
||||
provider.client = &http.Client{
|
||||
Timeout: time.Second * 10,
|
||||
}
|
||||
provider.cfg = &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: cfg.AuthURL,
|
||||
TokenURL: cfg.TokenURL,
|
||||
AuthStyle: oauth2.AuthStyleAutoDetect,
|
||||
},
|
||||
RedirectURL: callbackUrl,
|
||||
Scopes: cfg.Scopes,
|
||||
}
|
||||
provider.userInfoEndpoint = cfg.UserInfoURL
|
||||
provider.userInfoMapping = getOauthFieldMapping(cfg.FieldMap)
|
||||
provider.registrationEnabled = cfg.RegistrationEnabled
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (p PlainOauthAuthenticator) GetName() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p PlainOauthAuthenticator) RegistrationEnabled() bool {
|
||||
return p.registrationEnabled
|
||||
}
|
||||
|
||||
func (p PlainOauthAuthenticator) GetType() domain.AuthenticatorType {
|
||||
return domain.AuthenticatorTypeOAuth
|
||||
}
|
||||
|
||||
func (p PlainOauthAuthenticator) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
return p.cfg.AuthCodeURL(state, opts...)
|
||||
}
|
||||
|
||||
func (p PlainOauthAuthenticator) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
return p.cfg.Exchange(ctx, code, opts...)
|
||||
}
|
||||
|
||||
func (p PlainOauthAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, _ string) (map[string]interface{}, error) {
|
||||
req, err := http.NewRequest("GET", p.userInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create user info get request: %w", err)
|
||||
}
|
||||
req.Header.Add("Authorization", "Bearer "+token.AccessToken)
|
||||
req.WithContext(ctx)
|
||||
|
||||
response, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user info: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
contents, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
var userFields map[string]interface{}
|
||||
err = json.Unmarshal(contents, &userFields)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user info: %w", err)
|
||||
}
|
||||
|
||||
return userFields, nil
|
||||
}
|
||||
|
||||
func (p PlainOauthAuthenticator) ParseUserInfo(raw map[string]interface{}) (*domain.AuthenticatorUserInfo, error) {
|
||||
isAdmin, _ := strconv.ParseBool(internal.MapDefaultString(raw, p.userInfoMapping.IsAdmin, ""))
|
||||
userInfo := &domain.AuthenticatorUserInfo{
|
||||
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, p.userInfoMapping.UserIdentifier, "")),
|
||||
Email: internal.MapDefaultString(raw, p.userInfoMapping.Email, ""),
|
||||
Firstname: internal.MapDefaultString(raw, p.userInfoMapping.Firstname, ""),
|
||||
Lastname: internal.MapDefaultString(raw, p.userInfoMapping.Lastname, ""),
|
||||
Phone: internal.MapDefaultString(raw, p.userInfoMapping.Phone, ""),
|
||||
Department: internal.MapDefaultString(raw, p.userInfoMapping.Department, ""),
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
func getOauthFieldMapping(f config.OauthFields) config.OauthFields {
|
||||
defaultMap := config.OauthFields{
|
||||
BaseFields: config.BaseFields{
|
||||
UserIdentifier: "sub",
|
||||
Email: "email",
|
||||
Firstname: "given_name",
|
||||
Lastname: "family_name",
|
||||
Phone: "phone",
|
||||
Department: "department",
|
||||
},
|
||||
IsAdmin: "admin_flag",
|
||||
}
|
||||
if f.UserIdentifier != "" {
|
||||
defaultMap.UserIdentifier = f.UserIdentifier
|
||||
}
|
||||
if f.Email != "" {
|
||||
defaultMap.Email = f.Email
|
||||
}
|
||||
if f.Firstname != "" {
|
||||
defaultMap.Firstname = f.Firstname
|
||||
}
|
||||
if f.Lastname != "" {
|
||||
defaultMap.Lastname = f.Lastname
|
||||
}
|
||||
if f.Phone != "" {
|
||||
defaultMap.Phone = f.Phone
|
||||
}
|
||||
if f.Department != "" {
|
||||
defaultMap.Department = f.Department
|
||||
}
|
||||
if f.IsAdmin != "" {
|
||||
defaultMap.IsAdmin = f.IsAdmin
|
||||
}
|
||||
|
||||
return defaultMap
|
||||
}
|
107
internal/app/auth/auth_oidc.go
Normal file
107
internal/app/auth/auth_oidc.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type OidcAuthenticator struct {
|
||||
name string
|
||||
provider *oidc.Provider
|
||||
verifier *oidc.IDTokenVerifier
|
||||
cfg *oauth2.Config
|
||||
userInfoMapping config.OauthFields
|
||||
registrationEnabled bool
|
||||
}
|
||||
|
||||
func newOidcAuthenticator(ctx context.Context, callbackUrl string, cfg *config.OpenIDConnectProvider) (*OidcAuthenticator, error) {
|
||||
var err error
|
||||
var provider = &OidcAuthenticator{}
|
||||
|
||||
provider.name = cfg.ProviderName
|
||||
provider.provider, err = oidc.NewProvider(context.Background(), cfg.BaseUrl) // use new context here, see https://github.com/coreos/go-oidc/issues/339
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create new oidc provider: %w", err)
|
||||
}
|
||||
provider.verifier = provider.provider.Verifier(&oidc.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
})
|
||||
|
||||
scopes := []string{oidc.ScopeOpenID}
|
||||
scopes = append(scopes, cfg.ExtraScopes...)
|
||||
provider.cfg = &oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
Endpoint: provider.provider.Endpoint(),
|
||||
RedirectURL: callbackUrl,
|
||||
Scopes: scopes,
|
||||
}
|
||||
provider.userInfoMapping = getOauthFieldMapping(cfg.FieldMap)
|
||||
provider.registrationEnabled = cfg.RegistrationEnabled
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (o OidcAuthenticator) GetName() string {
|
||||
return o.name
|
||||
}
|
||||
|
||||
func (o OidcAuthenticator) RegistrationEnabled() bool {
|
||||
return o.registrationEnabled
|
||||
}
|
||||
|
||||
func (o OidcAuthenticator) GetType() domain.AuthenticatorType {
|
||||
return domain.AuthenticatorTypeOidc
|
||||
}
|
||||
|
||||
func (o OidcAuthenticator) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||
return o.cfg.AuthCodeURL(state, opts...)
|
||||
}
|
||||
|
||||
func (o OidcAuthenticator) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||
return o.cfg.Exchange(ctx, code, opts...)
|
||||
}
|
||||
|
||||
func (o OidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) (map[string]interface{}, error) {
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, errors.New("token does not contain id_token")
|
||||
}
|
||||
idToken, err := o.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to validate id_token: %w", err)
|
||||
}
|
||||
if idToken.Nonce != nonce {
|
||||
return nil, errors.New("nonce mismatch")
|
||||
}
|
||||
|
||||
var tokenFields map[string]interface{}
|
||||
if err = idToken.Claims(&tokenFields); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse extra claims: %w", err)
|
||||
}
|
||||
|
||||
return tokenFields, nil
|
||||
}
|
||||
|
||||
func (o OidcAuthenticator) ParseUserInfo(raw map[string]interface{}) (*domain.AuthenticatorUserInfo, error) {
|
||||
isAdmin, _ := strconv.ParseBool(internal.MapDefaultString(raw, o.userInfoMapping.IsAdmin, ""))
|
||||
userInfo := &domain.AuthenticatorUserInfo{
|
||||
Identifier: domain.UserIdentifier(internal.MapDefaultString(raw, o.userInfoMapping.UserIdentifier, "")),
|
||||
Email: internal.MapDefaultString(raw, o.userInfoMapping.Email, ""),
|
||||
Firstname: internal.MapDefaultString(raw, o.userInfoMapping.Firstname, ""),
|
||||
Lastname: internal.MapDefaultString(raw, o.userInfoMapping.Lastname, ""),
|
||||
Phone: internal.MapDefaultString(raw, o.userInfoMapping.Phone, ""),
|
||||
Department: internal.MapDefaultString(raw, o.userInfoMapping.Department, ""),
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
return userInfo, nil
|
||||
}
|
Reference in New Issue
Block a user