Login page styling/structure for users, screen readers, passwd managers

Also:
- Add CSS to login page
- Refactor login page HTML and CSS into a new package
- New custom CSP headers for the login page, because the requirements
  are different from the form_post page
This commit is contained in:
Ryan Richard 2022-05-05 13:12:06 -07:00
parent 6ca7c932ae
commit cffa353ffb
18 changed files with 449 additions and 173 deletions

View File

@ -2566,7 +2566,7 @@ func TestAuthorizationEndpoint(t *testing.T) {
require.Equal(t, test.wantStatus, rsp.Code) require.Equal(t, test.wantStatus, rsp.Code)
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), test.wantContentType)
testutil.RequireSecurityHeadersWithoutFormPostCSPs(t, rsp) testutil.RequireSecurityHeadersWithoutCustomCSPs(t, rsp)
if test.wantPasswordGrantCall != nil { if test.wantPasswordGrantCall != nil {
test.wantPasswordGrantCall.args.Ctx = reqContext test.wantPasswordGrantCall.args.Ctx = reqContext

View File

@ -1034,7 +1034,7 @@ func TestCallbackEndpoint(t *testing.T) {
t.Logf("response: %#v", rsp) t.Logf("response: %#v", rsp)
t.Logf("response body: %q", rsp.Body.String()) t.Logf("response body: %q", rsp.Body.String())
testutil.RequireSecurityHeadersWithFormPostCSPs(t, rsp) testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp)
if test.wantAuthcodeExchangeCall != nil { if test.wantAuthcodeExchangeCall != nil {
test.wantAuthcodeExchangeCall.args.Ctx = reqContext test.wantAuthcodeExchangeCall.args.Ctx = reqContext

View File

@ -4,53 +4,39 @@
package login package login
import ( import (
_ "embed"
"html/template"
"net/http" "net/http"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/login/loginhtml"
) )
const defaultErrorMessage = "An internal error occurred. Please contact your administrator for help." const (
internalErrorMessage = "An internal error occurred. Please contact your administrator for help."
var ( incorrectUsernameOrPasswordErrorMessage = "Incorrect username or password."
//go:embed login_form.gohtml
rawHTMLTemplate string
errorMappings = map[string]string{
"login_error": "Incorrect username or password.",
}
) )
type PageData struct { func NewGetHandler() HandlerFunc {
State string
IDPName string
HasAlertError bool
AlertMessage string
Title string
PostPath string
}
func NewGetHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) HandlerFunc {
var parsedHTMLTemplate = template.Must(template.New("login_post.gohtml").Parse(rawHTMLTemplate))
return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error { return func(w http.ResponseWriter, r *http.Request, encodedState string, decodedState *oidc.UpstreamStateParamData) error {
alertError := r.URL.Query().Get("err") alertMessage, hasAlert := getAlert(r)
message := errorMappings[alertError]
if message == "" { pageInputs := &loginhtml.PageData{
message = defaultErrorMessage PostPath: r.URL.Path, // the path for POST is the same as for GET
}
err := parsedHTMLTemplate.Execute(w, &PageData{
State: encodedState, State: encodedState,
IDPName: decodedState.UpstreamName, IDPName: decodedState.UpstreamName,
HasAlertError: alertError != "", HasAlertError: hasAlert,
AlertMessage: message, AlertMessage: alertMessage,
Title: "Pinniped", }
PostPath: r.URL.Path, // the path for POST is the same as for GET return loginhtml.Template().Execute(w, pageInputs)
}) }
if err != nil {
return err
} }
return nil func getAlert(r *http.Request) (string, bool) {
errorParamValue := r.URL.Query().Get(errParamName)
message := internalErrorMessage
if errorParamValue == string(ShowBadUserPassErr) {
message = incorrectUsernameOrPasswordErrorMessage
} }
return message, errorParamValue != ""
} }

View File

@ -4,21 +4,23 @@
package login package login
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"go.pinniped.dev/internal/testutil"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/login/loginhtml"
"go.pinniped.dev/internal/testutil"
) )
func TestGetLogin(t *testing.T) { func TestGetLogin(t *testing.T) {
const ( const (
happyLdapIDPName = "some-ldap-idp" testPath = "/some/path/login"
testUpstreamName = "some-ldap-idp"
testUpstreamType = "ldap"
testEncodedState = "fake-encoded-state-value"
) )
tests := []struct { tests := []struct {
@ -34,63 +36,57 @@ func TestGetLogin(t *testing.T) {
{ {
name: "Happy path ldap", name: "Happy path ldap",
decodedState: &oidc.UpstreamStateParamData{ decodedState: &oidc.UpstreamStateParamData{
UpstreamName: happyLdapIDPName, UpstreamName: testUpstreamName,
UpstreamType: "ldap", UpstreamType: testUpstreamType,
}, },
encodedState: "foo", // the encoded and decoded state don't match, but that verification is handled one level up. encodedState: testEncodedState, // the encoded and decoded state don't match, but that verification is handled one level up.
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantBody: getHTMLResult(""), wantBody: testutil.ExpectedLoginPageHTML(loginhtml.CSS(), testUpstreamName, testPath, testEncodedState, ""), // no alert message
}, },
{ {
name: "displays error banner when err=login_error param is sent", name: "displays error banner when err=login_error param is sent",
decodedState: &oidc.UpstreamStateParamData{ decodedState: &oidc.UpstreamStateParamData{
UpstreamName: happyLdapIDPName, UpstreamName: testUpstreamName,
UpstreamType: "ldap", UpstreamType: testUpstreamType,
}, },
encodedState: "foo", encodedState: testEncodedState,
errParam: "login_error", errParam: "login_error",
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantBody: getHTMLResult(` wantBody: testutil.ExpectedLoginPageHTML(loginhtml.CSS(), testUpstreamName, testPath, testEncodedState,
<div class="alert"> "Incorrect username or password.",
<span>Incorrect username or password.</span> ),
</div>
`),
}, },
{ {
name: "displays error banner when err=internal_error param is sent", name: "displays error banner when err=internal_error param is sent",
decodedState: &oidc.UpstreamStateParamData{ decodedState: &oidc.UpstreamStateParamData{
UpstreamName: happyLdapIDPName, UpstreamName: testUpstreamName,
UpstreamType: "ldap", UpstreamType: testUpstreamType,
}, },
encodedState: "foo", encodedState: testEncodedState,
errParam: "internal_error", errParam: "internal_error",
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantBody: getHTMLResult(` wantBody: testutil.ExpectedLoginPageHTML(loginhtml.CSS(), testUpstreamName, testPath, testEncodedState,
<div class="alert"> "An internal error occurred. Please contact your administrator for help.",
<span>An internal error occurred. Please contact your administrator for help.</span> ),
</div>
`),
}, },
{
// If we get an error that we don't recognize, that's also an error, so we // If we get an error that we don't recognize, that's also an error, so we
// should probably just tell you to contact your administrator... // should probably just tell you to contact your administrator...
{
name: "displays generic error banner when unrecognized err param is sent", name: "displays generic error banner when unrecognized err param is sent",
decodedState: &oidc.UpstreamStateParamData{ decodedState: &oidc.UpstreamStateParamData{
UpstreamName: happyLdapIDPName, UpstreamName: testUpstreamName,
UpstreamType: "ldap", UpstreamType: testUpstreamType,
}, },
encodedState: "foo", encodedState: testEncodedState,
errParam: "some_other_error", errParam: "some_other_error",
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantContentType: htmlContentType, wantContentType: htmlContentType,
wantBody: getHTMLResult(` wantBody: testutil.ExpectedLoginPageHTML(loginhtml.CSS(), testUpstreamName, testPath, testEncodedState,
<div class="alert"> "An internal error occurred. Please contact your administrator for help.",
<span>An internal error occurred. Please contact your administrator for help.</span> ),
</div>
`),
}, },
} }
@ -100,8 +96,8 @@ func TestGetLogin(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel() t.Parallel()
handler := NewGetHandler(tt.idps) handler := NewGetHandler()
target := "/some/path/login?state=" + tt.encodedState target := testPath + "?state=" + tt.encodedState
if tt.errParam != "" { if tt.errParam != "" {
target += "&err=" + tt.errParam target += "&err=" + tt.errParam
} }
@ -113,44 +109,8 @@ func TestGetLogin(t *testing.T) {
require.Equal(t, tt.wantStatus, rsp.Code) require.Equal(t, tt.wantStatus, rsp.Code)
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType) testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType)
body := rsp.Body.String() body := rsp.Body.String()
// t.Log("actual body:", body) // useful when updating expected values
require.Equal(t, tt.wantBody, body) require.Equal(t, tt.wantBody, body)
}) })
} }
} }
func getHTMLResult(errorBanner string) string {
happyGetResult := `<!DOCTYPE html>
<html>
<head>
<title>Pinniped</title>
</head>
<body>
<h1>Pinniped</h1>
<p>some-ldap-idp</p>
%s
<form action="/some/path/login" method="post" target="_parent">
<div>
<label for="username"><b>Username</b></label>
<input type="text" name="username" id="username" autocomplete="username" required>
</div>
<div>
<label for="password"><b>Password</b></label>
<input type="password" name="password" id="password current-password" autocomplete="current-password" required>
</div>
<div>
<input type="hidden" name="state" id="state" value="foo">
</div>
<button type="submit" name="submit" id="submit">Log in</button>
</form>
</body>
</html>
`
return fmt.Sprintf(happyGetResult, errorBanner)
}

View File

@ -1,39 +0,0 @@
<!--
Copyright 2022 the Pinniped contributors. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
--><!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
</head>
<body>
<h1>Pinniped</h1>
<p>{{ .IDPName }}</p>
{{if .HasAlertError}}
<div class="alert">
<span>{{.AlertMessage}}</span>
</div>
{{end}}
<form action="{{.PostPath}}" method="post" target="_parent">
<div>
<label for="username"><b>Username</b></label>
<input type="text" name="username" id="username" autocomplete="username" required>
</div>
<div>
<label for="password"><b>Password</b></label>
<input type="password" name="password" id="password current-password" autocomplete="current-password" required>
</div>
<div>
<input type="hidden" name="state" id="state" value="{{.State}}">
</div>
<button type="submit" name="submit" id="submit">Log in</button>
</form>
</body>
</html>

View File

@ -11,6 +11,7 @@ import (
"go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/login/loginhtml"
"go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/internal/oidc/provider/formposthtml"
"go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/plog"
) )
@ -84,7 +85,7 @@ func NewHandler(
func wrapSecurityHeaders(handler http.Handler) http.Handler { func wrapSecurityHeaders(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
wrapped := securityheader.Wrap(handler) wrapped := securityheader.WrapWithCustomCSP(handler, loginhtml.ContentSecurityPolicy())
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
// POST requests can result in the form_post html page, so allow it with CSP headers. // POST requests can result in the form_post html page, so allow it with CSP headers.
wrapped = securityheader.WrapWithCustomCSP(handler, formposthtml.ContentSecurityPolicy()) wrapped = securityheader.WrapWithCustomCSP(handler, formposthtml.ContentSecurityPolicy())

View File

@ -417,9 +417,9 @@ func TestLoginEndpoint(t *testing.T) {
subject.ServeHTTP(rsp, req) subject.ServeHTTP(rsp, req)
if tt.method == http.MethodPost { if tt.method == http.MethodPost {
testutil.RequireSecurityHeadersWithFormPostCSPs(t, rsp) testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp)
} else { } else {
testutil.RequireSecurityHeadersWithoutFormPostCSPs(t, rsp) testutil.RequireSecurityHeadersWithLoginPageCSPs(t, rsp)
} }
require.Equal(t, tt.wantStatus, rsp.Code) require.Equal(t, tt.wantStatus, rsp.Code)

View File

@ -0,0 +1,94 @@
/* Copyright 2022 the Pinniped contributors. All Rights Reserved. */
/* SPDX-License-Identifier: Apache-2.0 */
html {
height: 100%;
}
body {
font-family: "Metropolis-Light", Helvetica, sans-serif;
display: flex;
flex-flow: column wrap;
justify-content: flex-start;
align-items: center;
/* subtle gradient make the login box stand out */
background: linear-gradient(to top, #f8f8f8, white);
min-height: 100%;
}
h1 {
font-size: 20px;
margin: 0;
}
.box {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
border-radius: 4px;
border-color: #ddd;
border-width: 1px;
border-style: solid;
width: 400px;
padding:30px 30px 0;
margin: 60px 20px 0;
background: white;
font-size: 14px;
}
input {
color: inherit;
font: inherit;
border: 0;
margin: 0;
outline: 0;
padding: 0;
}
.form-field {
display: flex;
margin-bottom: 30px;
}
.form-field input[type="password"], .form-field input[type="text"], .form-field input[type="submit"] {
width: 100%;
padding: 1em;
}
.form-field input[type="password"], .form-field input[type="text"] {
border-radius: 3px;
border-width: 1px;
border-style: solid;
border-color: #a6a6a6;
}
.form-field input[type="submit"] {
background-color: #218fcf; /* this is a color from the Pinniped logo :) */
color: #eee;
font-weight: bold;
cursor: pointer;
transition: all .3s;
}
.form-field input[type="submit"]:focus, .form-field input[type="submit"]:hover {
background-color: #1abfd3; /* this is a color from the Pinniped logo :) */
}
.form-field input[type="submit"]:active {
transform: scale(.99);
}
.hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.alert {
color: crimson;
}

View File

@ -0,0 +1,40 @@
<!--
Copyright 2022 the Pinniped contributors. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
--><!DOCTYPE html>
<html lang="en">
<head>
<title>Pinniped</title>
<meta charset="UTF-8">
<style>{{minifiedCSS}}</style>
<link id="favicon" rel="icon"/>
</head>
<body>
<div class="box" aria-label="login form" role="main">
<div class="form-field">
<h1>Log in to {{.IDPName}}</h1>
</div>
{{if .HasAlertError}}
<div class="form-field">
<span class="alert" role="alert" aria-label="login error message">{{.AlertMessage}}</span>
</div>
{{end}}
<form action="{{.PostPath}}" method="post">
<input type="hidden" name="state" id="state" value="{{.State}}">
<div class="form-field">
<label for="username"><span class="hidden" aria-hidden="true">Username</span></label>
<input type="text" name="username" id="username"
autocomplete="username" placeholder="Username" required>
</div>
<div class="form-field">
<label for="password"><span class="hidden" aria-hidden="true">Password</span></label>
<input type="password" name="password" id="password"
autocomplete="current-password" placeholder="Password" required>
</div>
<div class="form-field">
<input type="submit" name="submit" id="submit" value="Log in"/>
</div>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,65 @@
// Copyright 2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package loginhtml defines HTML templates used by the Supervisor.
//nolint: gochecknoglobals // This package uses globals to ensure that all parsing and minifying happens at init.
package loginhtml
import (
_ "embed" // Needed to trigger //go:embed directives below.
"html/template"
"strings"
"github.com/tdewolff/minify/v2/minify"
"go.pinniped.dev/internal/oidc/provider/csp"
)
var (
//go:embed login_form.css
rawCSS string
minifiedCSS = mustMinify(minify.CSS(rawCSS))
//go:embed login_form.gohtml
rawHTMLTemplate string
)
// Parse the Go templated HTML and inject functions providing the minified inline CSS and JS.
var parsedHTMLTemplate = template.Must(template.New("login_form.gohtml").Funcs(template.FuncMap{
"minifiedCSS": func() template.CSS { return template.CSS(minifiedCSS) },
}).Parse(rawHTMLTemplate))
// Generate the CSP header value once since it's effectively constant.
var cspValue = strings.Join([]string{
`default-src 'none'`,
`style-src '` + csp.Hash(minifiedCSS) + `'`,
`frame-ancestors 'none'`,
}, "; ")
func mustMinify(s string, err error) string {
if err != nil {
panic(err)
}
return s
}
// ContentSecurityPolicy returns the Content-Security-Policy header value to make the Template() operate correctly.
//
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy.
func ContentSecurityPolicy() string { return cspValue }
// Template returns the html/template.Template for rendering the login page.
func Template() *template.Template { return parsedHTMLTemplate }
// CSS returns the minified CSS that will be embedded into the page template.
func CSS() string { return minifiedCSS }
// PageData represents the inputs to the template.
type PageData struct {
State string
IDPName string
HasAlertError bool
AlertMessage string
MinifiedCSS template.CSS
PostPath string
}

View File

@ -0,0 +1,68 @@
// Copyright 2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package loginhtml
import (
"bytes"
"fmt"
"testing"
"go.pinniped.dev/internal/testutil"
"github.com/stretchr/testify/require"
)
var (
testExpectedCSS = `html{height:100%}body{font-family:metropolis-light,Helvetica,sans-serif;display:flex;flex-flow:column wrap;justify-content:flex-start;align-items:center;background:linear-gradient(to top,#f8f8f8,white);min-height:100%}h1{font-size:20px;margin:0}.box{display:flex;flex-direction:column;flex-wrap:nowrap;border-radius:4px;border-color:#ddd;border-width:1px;border-style:solid;width:400px;padding:30px 30px 0;margin:60px 20px 0;background:#fff;font-size:14px}input{color:inherit;font:inherit;border:0;margin:0;outline:0;padding:0}.form-field{display:flex;margin-bottom:30px}.form-field input[type=password],.form-field input[type=text],.form-field input[type=submit]{width:100%;padding:1em}.form-field input[type=password],.form-field input[type=text]{border-radius:3px;border-width:1px;border-style:solid;border-color:#a6a6a6}.form-field input[type=submit]{background-color:#218fcf;color:#eee;font-weight:700;cursor:pointer;transition:all .3s}.form-field input[type=submit]:focus,.form-field input[type=submit]:hover{background-color:#1abfd3}.form-field input[type=submit]:active{transform:scale(.99)}.hidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.alert{color:crimson}`
// It's okay if this changes in the future, but this gives us a chance to eyeball the formatting.
// Our browser-based integration tests should find any incompatibilities.
testExpectedCSP = `default-src 'none'; ` +
`style-src 'sha256-QC9ckaUFAdcN0Ysmu8q8iqCazYFgrJSQDJPa/przPXU='; ` +
`frame-ancestors 'none'`
)
func TestTemplate(t *testing.T) {
const (
testUpstreamName = "test-idp-name"
testPath = "test-post-path"
testEncodedState = "test-encoded-state"
testAlert = "test-alert-message"
)
var buf bytes.Buffer
pageInputs := &PageData{
PostPath: testPath,
State: testEncodedState,
IDPName: testUpstreamName,
HasAlertError: true,
AlertMessage: testAlert,
}
// Render with an alert.
expectedHTMLWithAlert := testutil.ExpectedLoginPageHTML(testExpectedCSS, testUpstreamName, testPath, testEncodedState, testAlert)
require.NoError(t, Template().Execute(&buf, pageInputs))
// t.Logf("actual value:\n%s", buf.String()) // useful when updating minify library causes new output
require.Equal(t, expectedHTMLWithAlert, buf.String())
// Render again without an alert.
pageInputs.HasAlertError = false
expectedHTMLWithoutAlert := testutil.ExpectedLoginPageHTML(testExpectedCSS, testUpstreamName, testPath, testEncodedState, "")
buf = bytes.Buffer{} // clear previous result from buffer
require.NoError(t, Template().Execute(&buf, pageInputs))
require.Equal(t, expectedHTMLWithoutAlert, buf.String())
}
func TestContentSecurityPolicy(t *testing.T) {
require.Equal(t, testExpectedCSP, ContentSecurityPolicy())
}
func TestCSS(t *testing.T) {
require.Equal(t, testExpectedCSS, CSS())
}
func TestHelpers(t *testing.T) {
require.Equal(t, "test", mustMinify("test", nil))
require.PanicsWithError(t, "some error", func() { mustMinify("", fmt.Errorf("some error")) })
}

View File

@ -0,0 +1,15 @@
// Copyright 2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package csp defines helpers related to HTML Content Security Policies.
package csp
import (
"crypto/sha256"
"encoding/base64"
)
func Hash(s string) string {
hashBytes := sha256.Sum256([]byte(s))
return "sha256-" + base64.StdEncoding.EncodeToString(hashBytes[:])
}

View File

@ -0,0 +1,15 @@
// Copyright 2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package csp
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestHash(t *testing.T) {
// Example test vector from https://content-security-policy.com/hash/.
require.Equal(t, "sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=", Hash("doSomething();"))
}

View File

@ -6,13 +6,13 @@
package formposthtml package formposthtml
import ( import (
"crypto/sha256"
_ "embed" // Needed to trigger //go:embed directives below. _ "embed" // Needed to trigger //go:embed directives below.
"encoding/base64"
"html/template" "html/template"
"strings" "strings"
"github.com/tdewolff/minify/v2/minify" "github.com/tdewolff/minify/v2/minify"
"go.pinniped.dev/internal/oidc/provider/csp"
) )
var ( var (
@ -37,8 +37,8 @@ var parsedHTMLTemplate = template.Must(template.New("form_post.gohtml").Funcs(te
// Generate the CSP header value once since it's effectively constant. // Generate the CSP header value once since it's effectively constant.
var cspValue = strings.Join([]string{ var cspValue = strings.Join([]string{
`default-src 'none'`, `default-src 'none'`,
`script-src '` + cspHash(minifiedJS) + `'`, `script-src '` + csp.Hash(minifiedJS) + `'`,
`style-src '` + cspHash(minifiedCSS) + `'`, `style-src '` + csp.Hash(minifiedCSS) + `'`,
`img-src data:`, `img-src data:`,
`connect-src *`, `connect-src *`,
`frame-ancestors 'none'`, `frame-ancestors 'none'`,
@ -51,14 +51,9 @@ func mustMinify(s string, err error) string {
return s return s
} }
func cspHash(s string) string {
hashBytes := sha256.Sum256([]byte(s))
return "sha256-" + base64.StdEncoding.EncodeToString(hashBytes[:])
}
// ContentSecurityPolicy returns the Content-Security-Policy header value to make the Template() operate correctly. // ContentSecurityPolicy returns the Content-Security-Policy header value to make the Template() operate correctly.
// //
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src#:~:text=%27%3Chash-algorithm%3E-%3Cbase64-value%3E%27. // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy.
func ContentSecurityPolicy() string { return cspValue } func ContentSecurityPolicy() string { return cspValue }
// Template returns the html/template.Template for rendering the response_type=form_post response page. // Template returns the html/template.Template for rendering the response_type=form_post response page.

View File

@ -93,10 +93,6 @@ func TestContentSecurityPolicyHashes(t *testing.T) {
} }
func TestHelpers(t *testing.T) { func TestHelpers(t *testing.T) {
// These are silly tests but it's easy to we might as well have them.
require.Equal(t, "test", mustMinify("test", nil)) require.Equal(t, "test", mustMinify("test", nil))
require.PanicsWithError(t, "some error", func() { mustMinify("", fmt.Errorf("some error")) }) require.PanicsWithError(t, "some error", func() { mustMinify("", fmt.Errorf("some error")) })
// Example test vector from https://content-security-policy.com/hash/.
require.Equal(t, "sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc=", cspHash("doSomething();"))
} }

View File

@ -139,7 +139,7 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs
m.providerHandlers[(issuerHostWithPath + oidc.PinnipedLoginPath)] = login.NewHandler( m.providerHandlers[(issuerHostWithPath + oidc.PinnipedLoginPath)] = login.NewHandler(
upstreamStateEncoder, upstreamStateEncoder,
csrfCookieEncoder, csrfCookieEncoder,
login.NewGetHandler(m.upstreamIDPs), login.NewGetHandler(),
login.NewPostHandler(issuer, m.upstreamIDPs, oauthHelperWithKubeStorage), login.NewPostHandler(issuer, m.upstreamIDPs, oauthHelperWithKubeStorage),
) )

View File

@ -54,7 +54,7 @@ func RequireNumberOfSecretsMatchingLabelSelector(t *testing.T, secrets v1.Secret
require.Len(t, storedAuthcodeSecrets.Items, expectedNumberOfSecrets) require.Len(t, storedAuthcodeSecrets.Items, expectedNumberOfSecrets)
} }
func RequireSecurityHeadersWithFormPostCSPs(t *testing.T, response *httptest.ResponseRecorder) { func RequireSecurityHeadersWithFormPostPageCSPs(t *testing.T, response *httptest.ResponseRecorder) {
// Loosely confirm that the unique CSPs needed for the form_post page were used. // Loosely confirm that the unique CSPs needed for the form_post page were used.
cspHeader := response.Header().Get("Content-Security-Policy") cspHeader := response.Header().Get("Content-Security-Policy")
require.Contains(t, cspHeader, "script-src '") // loose assertion require.Contains(t, cspHeader, "script-src '") // loose assertion
@ -66,8 +66,20 @@ func RequireSecurityHeadersWithFormPostCSPs(t *testing.T, response *httptest.Res
requireSecurityHeaders(t, response) requireSecurityHeaders(t, response)
} }
func RequireSecurityHeadersWithoutFormPostCSPs(t *testing.T, response *httptest.ResponseRecorder) { func RequireSecurityHeadersWithLoginPageCSPs(t *testing.T, response *httptest.ResponseRecorder) {
// Confirm that the unique CSPs needed for the form_post page were NOT used. // Loosely confirm that the unique CSPs needed for the login page were used.
cspHeader := response.Header().Get("Content-Security-Policy")
require.Contains(t, cspHeader, "style-src '") // loose assertion
require.NotContains(t, cspHeader, "script-src") // only needed by form_post page
require.NotContains(t, cspHeader, "img-src data:") // only needed by form_post page
require.NotContains(t, cspHeader, "connect-src *") // only needed by form_post page
// Also require all the usual security headers.
requireSecurityHeaders(t, response)
}
func RequireSecurityHeadersWithoutCustomCSPs(t *testing.T, response *httptest.ResponseRecorder) {
// Confirm that the unique CSPs needed for the form_post or login page were NOT used.
cspHeader := response.Header().Get("Content-Security-Policy") cspHeader := response.Header().Get("Content-Security-Policy")
require.NotContains(t, cspHeader, "script-src") require.NotContains(t, cspHeader, "script-src")
require.NotContains(t, cspHeader, "style-src") require.NotContains(t, cspHeader, "style-src")
@ -79,7 +91,7 @@ func RequireSecurityHeadersWithoutFormPostCSPs(t *testing.T, response *httptest.
} }
func requireSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) { func requireSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
// Loosely confirm that the generic CSPs were used. // Loosely confirm that the generic default CSPs were used.
cspHeader := response.Header().Get("Content-Security-Policy") cspHeader := response.Header().Get("Content-Security-Policy")
require.Contains(t, cspHeader, "default-src 'none'") require.Contains(t, cspHeader, "default-src 'none'")
require.Contains(t, cspHeader, "frame-ancestors 'none'") require.Contains(t, cspHeader, "frame-ancestors 'none'")

View File

@ -0,0 +1,68 @@
// Copyright 2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package testutil
import (
"fmt"
"go.pinniped.dev/internal/here"
)
func ExpectedLoginPageHTML(wantCSS, wantIDPName, wantPostPath, wantEncodedState, wantAlert string) string {
alertHTML := ""
if wantAlert != "" {
alertHTML = fmt.Sprintf("\n"+
" <div class=\"form-field\">\n"+
" <span class=\"alert\" role=\"alert\" aria-label=\"login error message\">%s</span>\n"+
" </div>\n ",
wantAlert,
)
}
// Note that "role", "aria-*", and "alert" attributes are hints to screen readers.
// Also note that some structure and attributes used here are hints to password managers,
// see https://support.1password.com/compatible-website-design/.
// Please take care when changing the HTML of this form,
// and test with a screen reader and password manager after changes.
return here.Docf(`<!DOCTYPE html>
<html lang="en">
<head>
<title>Pinniped</title>
<meta charset="UTF-8">
<style>%s</style>
<link id="favicon" rel="icon"/>
</head>
<body>
<div class="box" aria-label="login form" role="main">
<div class="form-field">
<h1>Log in to %s</h1>
</div>
%s
<form action="%s" method="post">
<input type="hidden" name="state" id="state" value="%s">
<div class="form-field">
<label for="username"><span class="hidden" aria-hidden="true">Username</span></label>
<input type="text" name="username" id="username"
autocomplete="username" placeholder="Username" required>
</div>
<div class="form-field">
<label for="password"><span class="hidden" aria-hidden="true">Password</span></label>
<input type="password" name="password" id="password"
autocomplete="current-password" placeholder="Password" required>
</div>
<div class="form-field">
<input type="submit" name="submit" id="submit" value="Log in"/>
</div>
</form>
</div>
</body>
</html>
`,
wantCSS,
wantIDPName,
alertHTML,
wantPostPath,
wantEncodedState,
)
}