mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
feat: add home page with user session info (#4677)
Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/dexidp/dex/storage"
|
||||
)
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sessionConfig == nil || s.templates.homeTmpl == nil {
|
||||
s.handleHomeInline(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
data := homeData{
|
||||
DiscoveryURL: s.issuerURL.JoinPath(".well-known", "openid-configuration").String(),
|
||||
LogoutURL: s.absURL("/logout"),
|
||||
}
|
||||
|
||||
if cookie, err := r.Cookie(s.sessionConfig.CookieName); err == nil && cookie.Value != "" {
|
||||
if userID, connectorID, nonce, err := parseSessionCookie(cookie.Value); err == nil {
|
||||
session, err := s.storage.GetAuthSession(ctx, userID, connectorID)
|
||||
if err == nil && session.Nonce == nonce {
|
||||
data.LoggedIn = true
|
||||
data.IPAddress = session.IPAddress
|
||||
data.UserAgent = session.UserAgent
|
||||
s.populateHomeData(ctx, &data, userID, connectorID)
|
||||
} else if err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
s.logger.ErrorContext(ctx, "home: failed to get auth session", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.templates.home(r, w, data); err != nil {
|
||||
s.logger.ErrorContext(ctx, "failed to render home template", "err", err)
|
||||
s.renderError(r, w, http.StatusInternalServerError, "Internal server error.")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleHomeInline(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<title>Dex</title>
|
||||
<h1>Dex IdP</h1>
|
||||
<h3>A Federated OpenID Connect Provider</h3>
|
||||
<p><a href=%q>Discovery</a></p>`,
|
||||
s.issuerURL.JoinPath(".well-known", "openid-configuration").String())
|
||||
if err != nil {
|
||||
s.logger.Error("failed to write response", "err", err)
|
||||
s.renderError(r, w, http.StatusInternalServerError, "Handling the / path error.")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) populateHomeData(ctx context.Context, data *homeData, userID, connectorID string) {
|
||||
ui, err := s.storage.GetUserIdentity(ctx, userID, connectorID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, storage.ErrNotFound) {
|
||||
s.logger.ErrorContext(ctx, "home: failed to get user identity", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data.Username = ui.Claims.PreferredUsername
|
||||
if data.Username == "" {
|
||||
data.Username = ui.Claims.Username
|
||||
}
|
||||
data.Email = ui.Claims.Email
|
||||
data.EmailVerified = ui.Claims.EmailVerified
|
||||
data.Groups = ui.Claims.Groups
|
||||
if !ui.LastLogin.IsZero() {
|
||||
data.LastLoginEpoch = ui.LastLogin.Unix()
|
||||
}
|
||||
|
||||
conn, err := s.storage.GetConnector(ctx, connectorID)
|
||||
if err == nil {
|
||||
data.ConnectorName = conn.Name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gosundheit "github.com/AppsFlyer/go-sundheit"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/dexidp/dex/server/signer"
|
||||
"github.com/dexidp/dex/storage"
|
||||
"github.com/dexidp/dex/storage/memory"
|
||||
)
|
||||
|
||||
func newTestServerWithSessions(t *testing.T, updateConfig func(c *Config)) (*httptest.Server, *Server) {
|
||||
t.Helper()
|
||||
|
||||
var server *Server
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
server.ServeHTTP(w, r)
|
||||
}))
|
||||
|
||||
logger := newLogger(t)
|
||||
ctx := t.Context()
|
||||
|
||||
sig, err := signer.NewMockSigner(testKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
config := Config{
|
||||
Issuer: s.URL,
|
||||
Storage: memory.New(logger),
|
||||
Web: WebConfig{
|
||||
Dir: "../web",
|
||||
},
|
||||
Logger: logger,
|
||||
PrometheusRegistry: prometheus.NewRegistry(),
|
||||
HealthChecker: gosundheit.New(),
|
||||
SkipApprovalScreen: true,
|
||||
AllowedGrantTypes: []string{
|
||||
grantTypeAuthorizationCode,
|
||||
grantTypeClientCredentials,
|
||||
grantTypeRefreshToken,
|
||||
grantTypeTokenExchange,
|
||||
grantTypeDeviceCode,
|
||||
},
|
||||
Signer: sig,
|
||||
SessionConfig: &SessionConfig{
|
||||
CookieName: "dex_session",
|
||||
AbsoluteLifetime: 24 * time.Hour,
|
||||
ValidIfNotUsedFor: 1 * time.Hour,
|
||||
},
|
||||
}
|
||||
if updateConfig != nil {
|
||||
updateConfig(&config)
|
||||
}
|
||||
s.URL = config.Issuer
|
||||
|
||||
connector := storage.Connector{
|
||||
ID: "mock",
|
||||
Type: "mockCallback",
|
||||
Name: "Mock",
|
||||
ResourceVersion: "1",
|
||||
}
|
||||
require.NoError(t, config.Storage.CreateConnector(ctx, connector))
|
||||
|
||||
server, err = newServer(ctx, config)
|
||||
require.NoError(t, err)
|
||||
|
||||
if server.refreshTokenPolicy == nil {
|
||||
server.refreshTokenPolicy, err = NewRefreshTokenPolicy(logger, false, "", "", "")
|
||||
require.NoError(t, err)
|
||||
server.refreshTokenPolicy.now = config.Now
|
||||
}
|
||||
|
||||
return s, server
|
||||
}
|
||||
|
||||
func TestHomeNoSessions(t *testing.T) {
|
||||
httpServer, server := newTestServer(t, nil)
|
||||
defer httpServer.Close()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
server.ServeHTTP(rr, httptest.NewRequest("GET", "/", nil))
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
body := rr.Body.String()
|
||||
require.Contains(t, body, "Dex IdP")
|
||||
require.Contains(t, body, "Discovery")
|
||||
require.NotContains(t, body, "Logout")
|
||||
}
|
||||
|
||||
func TestHomeNotLoggedIn(t *testing.T) {
|
||||
httpServer, server := newTestServerWithSessions(t, nil)
|
||||
defer httpServer.Close()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
server.ServeHTTP(rr, httptest.NewRequest("GET", "/", nil))
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
body := rr.Body.String()
|
||||
require.Contains(t, body, "Discovery")
|
||||
require.Contains(t, body, "Not logged in")
|
||||
require.NotContains(t, body, "Logout")
|
||||
}
|
||||
|
||||
func TestHomeLoggedIn(t *testing.T) {
|
||||
httpServer, server := newTestServerWithSessions(t, nil)
|
||||
defer httpServer.Close()
|
||||
|
||||
ctx := t.Context()
|
||||
userID := "test-user"
|
||||
connectorID := "mock"
|
||||
nonce := "testnonce"
|
||||
now := time.Now()
|
||||
|
||||
require.NoError(t, server.storage.CreateAuthSession(ctx, storage.AuthSession{
|
||||
UserID: userID,
|
||||
ConnectorID: connectorID,
|
||||
Nonce: nonce,
|
||||
CreatedAt: now,
|
||||
LastActivity: now,
|
||||
}))
|
||||
|
||||
require.NoError(t, server.storage.CreateUserIdentity(ctx, storage.UserIdentity{
|
||||
UserID: userID,
|
||||
ConnectorID: connectorID,
|
||||
Claims: storage.Claims{
|
||||
UserID: userID,
|
||||
Username: "Test User",
|
||||
PreferredUsername: "testuser",
|
||||
Email: "test@example.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"admins", "devs"},
|
||||
},
|
||||
LastLogin: now,
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "dex_session",
|
||||
Value: sessionCookieValue(userID, connectorID, nonce),
|
||||
})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
server.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
body := rr.Body.String()
|
||||
require.Contains(t, body, "Logout")
|
||||
require.Contains(t, body, "/logout")
|
||||
require.Contains(t, body, "testuser")
|
||||
require.Contains(t, body, "test@example.com")
|
||||
require.Contains(t, body, "Mock")
|
||||
require.Contains(t, body, "admins")
|
||||
require.Contains(t, body, "Discovery")
|
||||
require.NotContains(t, body, "Not logged in")
|
||||
}
|
||||
|
||||
func TestHomeInvalidCookie(t *testing.T) {
|
||||
httpServer, server := newTestServerWithSessions(t, nil)
|
||||
defer httpServer.Close()
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "dex_session",
|
||||
Value: "invalid-cookie-value",
|
||||
})
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
server.ServeHTTP(rr, req)
|
||||
require.Equal(t, http.StatusOK, rr.Code)
|
||||
|
||||
body := rr.Body.String()
|
||||
require.NotContains(t, body, "Logout")
|
||||
require.Contains(t, body, "Not logged in")
|
||||
require.Contains(t, body, "Discovery")
|
||||
}
|
||||
+1
-14
@@ -521,20 +521,7 @@ func newServer(ctx context.Context, c Config) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
handleWithCORS("/.well-known/openid-configuration", discoveryHandler)
|
||||
// Handle the root path for the better user experience.
|
||||
handleWithCORS("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<title>Dex</title>
|
||||
<h1>Dex IdP</h1>
|
||||
<h3>A Federated OpenID Connect Provider</h3>
|
||||
<p><a href=%q>Discovery</a></p>`,
|
||||
s.issuerURL.JoinPath(".well-known", "openid-configuration").String())
|
||||
if err != nil {
|
||||
s.logger.Error("failed to write response", "err", err)
|
||||
s.renderError(r, w, http.StatusInternalServerError, "Handling the / path error.")
|
||||
return
|
||||
}
|
||||
})
|
||||
handleWithCORS("/", s.handleHome)
|
||||
|
||||
// TODO(ericchiang): rate limit certain paths based on IP.
|
||||
handleWithCORS("/token", s.handleToken)
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
tmplDevice = "device.html"
|
||||
tmplDeviceSuccess = "device_success.html"
|
||||
tmplTOTPVerify = "totp_verify.html"
|
||||
tmplHome = "home.html"
|
||||
)
|
||||
|
||||
var requiredTmpls = []string{
|
||||
@@ -44,6 +45,7 @@ type templates struct {
|
||||
deviceTmpl *template.Template
|
||||
deviceSuccessTmpl *template.Template
|
||||
totpVerifyTmpl *template.Template
|
||||
homeTmpl *template.Template
|
||||
}
|
||||
|
||||
type webConfig struct {
|
||||
@@ -172,6 +174,7 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) {
|
||||
deviceTmpl: tmpls.Lookup(tmplDevice),
|
||||
deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess),
|
||||
totpVerifyTmpl: tmpls.Lookup(tmplTOTPVerify),
|
||||
homeTmpl: tmpls.Lookup(tmplHome),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -228,6 +231,13 @@ func relativeURL(serverPath, reqPath, assetPath string) string {
|
||||
// Remove common prefix of request path with server path
|
||||
_, req = stripCommonParts(server, req)
|
||||
|
||||
// When the request is at the server root (e.g., reqPath == "/dex"),
|
||||
// the browser treats the last path segment as a file, not a directory.
|
||||
// Prepend the server path so relative URLs resolve correctly.
|
||||
if len(req) == 0 && len(server) > 0 {
|
||||
asset = append(server, asset...)
|
||||
}
|
||||
|
||||
// Remove common prefix of request path with asset path
|
||||
asset, req = stripCommonParts(asset, req)
|
||||
|
||||
@@ -356,6 +366,26 @@ func (t *templates) totpVerify(r *http.Request, w http.ResponseWriter, postURL,
|
||||
return renderTemplate(w, t.totpVerifyTmpl, data)
|
||||
}
|
||||
|
||||
type homeData struct {
|
||||
LoggedIn bool
|
||||
Username string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
Groups []string
|
||||
ConnectorName string
|
||||
LastLoginEpoch int64
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
LogoutURL string
|
||||
DiscoveryURL string
|
||||
ReqPath string
|
||||
}
|
||||
|
||||
func (t *templates) home(r *http.Request, w http.ResponseWriter, data homeData) error {
|
||||
data.ReqPath = r.URL.Path
|
||||
return renderTemplate(w, t.homeTmpl, data)
|
||||
}
|
||||
|
||||
func (t *templates) oob(r *http.Request, w http.ResponseWriter, code string) error {
|
||||
data := struct {
|
||||
Code string
|
||||
|
||||
@@ -31,6 +31,27 @@ func TestRelativeURL(t *testing.T) {
|
||||
assetPath: "assets/css/main.css",
|
||||
expected: "../assets/css/main.css",
|
||||
},
|
||||
{
|
||||
name: "server-root-page",
|
||||
serverPath: "/dex",
|
||||
reqPath: "/dex",
|
||||
assetPath: "static/main.css",
|
||||
expected: "dex/static/main.css",
|
||||
},
|
||||
{
|
||||
name: "server-root-page-nested",
|
||||
serverPath: "/dex/idp",
|
||||
reqPath: "/dex/idp",
|
||||
assetPath: "theme/styles.css",
|
||||
expected: "dex/idp/theme/styles.css",
|
||||
},
|
||||
{
|
||||
name: "server-root-no-subpath",
|
||||
serverPath: "/",
|
||||
reqPath: "/",
|
||||
assetPath: "static/main.css",
|
||||
expected: "static/main.css",
|
||||
},
|
||||
{
|
||||
name: "external-url",
|
||||
serverPath: "/dex",
|
||||
|
||||
@@ -146,6 +146,85 @@ body {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dex-info-table {
|
||||
margin: 12px auto;
|
||||
text-align: left;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.dex-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dex-info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dex-info-label {
|
||||
color: #888;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dex-info-value {
|
||||
color: #333;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.dex-info-details {
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dex-info-details summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #4A90D9;
|
||||
}
|
||||
|
||||
.dex-info-details summary:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dex-info-details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dex-info-details summary::after {
|
||||
content: " \25B8";
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.dex-info-details[open] summary::after {
|
||||
content: " \25BE";
|
||||
}
|
||||
|
||||
.dex-info-details[open] > :not(summary) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dex-info-details[open] summary {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dex-info-details .dex-info-details__list {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.dex-error-box {
|
||||
background-color: #e5383b;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{{ define "info-value" }}
|
||||
{{ if gt (len .) 24 }}
|
||||
<details class="dex-info-details">
|
||||
<summary>{{ . }}</summary>
|
||||
<div>{{ . }}</div>
|
||||
</details>
|
||||
{{ else }}
|
||||
<span class="dex-info-value">{{ . }}</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ template "header.html" . }}
|
||||
|
||||
<div class="theme-panel">
|
||||
<div style="text-align: center; margin-bottom: 16px;">
|
||||
<img src="{{ url .ReqPath logo }}" alt="{{ issuer }}" style="max-height: 36px;">
|
||||
</div>
|
||||
|
||||
{{ if .LoggedIn }}
|
||||
<hr class="dex-separator">
|
||||
<div class="dex-info-table">
|
||||
{{ if .Username }}
|
||||
<div class="dex-info-row">
|
||||
<span class="dex-info-label">Username</span>
|
||||
{{ template "info-value" .Username }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .Email }}
|
||||
<div class="dex-info-row">
|
||||
<span class="dex-info-label">Email</span>
|
||||
<span class="dex-info-value">{{ .Email }}{{ if .EmailVerified }} ✓{{ end }}</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .Groups }}
|
||||
<div class="dex-info-row">
|
||||
<span class="dex-info-label">Groups</span>
|
||||
<details class="dex-info-details">
|
||||
<summary>{{ len .Groups }} group{{ if gt (len .Groups) 1 }}s{{ end }}</summary>
|
||||
<ul class="dex-info-details__list">
|
||||
{{ range .Groups }}<li>{{ . }}</li>{{ end }}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .ConnectorName }}
|
||||
<div class="dex-info-row">
|
||||
<span class="dex-info-label">Connector</span>
|
||||
{{ template "info-value" .ConnectorName }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .IPAddress }}
|
||||
<div class="dex-info-row">
|
||||
<span class="dex-info-label">IP address</span>
|
||||
{{ template "info-value" .IPAddress }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .UserAgent }}
|
||||
<div class="dex-info-row">
|
||||
<span class="dex-info-label">Browser</span>
|
||||
{{ template "info-value" .UserAgent }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ if .LastLoginEpoch }}
|
||||
<div class="dex-info-row">
|
||||
<span class="dex-info-label">Last login</span>
|
||||
<span class="dex-info-value" id="last-login"></span>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<hr class="dex-separator">
|
||||
|
||||
<div class="theme-form-row">
|
||||
<a href="{{ .LogoutURL }}" class="dex-btn theme-btn--primary" style="display: inline-block; text-decoration: none; padding: 8px 16px;">Logout</a>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div>
|
||||
<div class="dex-subtle-text">Not logged in</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="theme-form-row">
|
||||
<a href="{{ .DiscoveryURL }}" class="dex-subtle-text">Discovery</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ if .LastLoginEpoch }}
|
||||
<script>
|
||||
(function() {
|
||||
var epoch = {{ .LastLoginEpoch }};
|
||||
var el = document.getElementById('last-login');
|
||||
if (el && epoch) {
|
||||
el.textContent = new Date(epoch * 1000).toLocaleString();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{ template "footer.html" . }}
|
||||
Reference in New Issue
Block a user