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:
parent
6ca7c932ae
commit
cffa353ffb
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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 != ""
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
|
@ -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>
|
|
@ -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())
|
||||||
|
@ -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)
|
||||||
|
94
internal/oidc/login/loginhtml/login_form.css
Normal file
94
internal/oidc/login/loginhtml/login_form.css
Normal 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;
|
||||||
|
}
|
40
internal/oidc/login/loginhtml/login_form.gohtml
Normal file
40
internal/oidc/login/loginhtml/login_form.gohtml
Normal 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>
|
65
internal/oidc/login/loginhtml/loginhtml.go
Normal file
65
internal/oidc/login/loginhtml/loginhtml.go
Normal 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
|
||||||
|
}
|
68
internal/oidc/login/loginhtml/loginhtml_test.go
Normal file
68
internal/oidc/login/loginhtml/loginhtml_test.go
Normal 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")) })
|
||||||
|
}
|
15
internal/oidc/provider/csp/csp.go
Normal file
15
internal/oidc/provider/csp/csp.go
Normal 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[:])
|
||||||
|
}
|
15
internal/oidc/provider/csp/csp_test.go
Normal file
15
internal/oidc/provider/csp/csp_test.go
Normal 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();"))
|
||||||
|
}
|
@ -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.
|
||||||
|
@ -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();"))
|
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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'")
|
||||||
|
68
internal/testutil/loginhtml.go
Normal file
68
internal/testutil/loginhtml.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user