Jan Dittberner
16a3dbedc8
- move internal code to internal directory - add translations for texts on missing email in client certificate page - add error handling for missing login_challenge request parameter - add Markdown support via goldmark - use https:// URLs in Apache license headers
375 lines
10 KiB
Go
375 lines
10 KiB
Go
/*
|
|
Copyright 2020-2023 CAcert Inc.
|
|
SPDX-License-Identifier: Apache-2.0
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
https://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gorilla/csrf"
|
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
|
"github.com/ory/hydra-client-go/client/admin"
|
|
"github.com/ory/hydra-client-go/models"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/yuin/goldmark"
|
|
|
|
"code.cacert.org/cacert/oidc_idp/ui"
|
|
|
|
"code.cacert.org/cacert/oidc_idp/internal/services"
|
|
)
|
|
|
|
type acrType string
|
|
|
|
const (
|
|
ClientCertificate acrType = "cert" // client certificate login
|
|
// ClientCertificateOTP acrType = "cert+otp"
|
|
// ClientCertificateToken acrType = "cert+token"
|
|
)
|
|
|
|
type templateName string
|
|
|
|
const (
|
|
CertificateLogin templateName = "cert"
|
|
NoEmailsInClientCertificate templateName = "no_emails"
|
|
NoChallengeInRequest templateName = "no_challenge"
|
|
)
|
|
|
|
const TimeoutTen = 10 * time.Second
|
|
|
|
type LoginHandler struct {
|
|
adminClient admin.ClientService
|
|
bundle *i18n.Bundle
|
|
logger *log.Logger
|
|
templates map[templateName]*template.Template
|
|
messageCatalog *services.MessageCatalog
|
|
}
|
|
|
|
func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
|
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
accept := r.Header.Get("Accept-Language")
|
|
localizer := i18n.NewLocalizer(h.bundle, accept)
|
|
|
|
challenge := r.URL.Query().Get("login_challenge")
|
|
|
|
if challenge == "" {
|
|
h.renderNoChallengeInRequest(w, localizer)
|
|
|
|
return
|
|
}
|
|
|
|
h.logger.Debugf("received login challenge %s\n", challenge)
|
|
|
|
certEmails := h.getEmailAddressesFromClientCertificate(r)
|
|
|
|
if certEmails == nil {
|
|
h.renderNoEmailsInClientCertificate(w, localizer)
|
|
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodGet {
|
|
h.handleGet(w, r, challenge, certEmails, localizer)
|
|
} else {
|
|
h.handlePost(w, r, challenge, certEmails, localizer)
|
|
}
|
|
}
|
|
|
|
func (h *LoginHandler) handleGet(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
challenge string,
|
|
certEmails []string,
|
|
localizer *i18n.Localizer,
|
|
) {
|
|
loginRequest, err := h.adminClient.GetLoginRequest(admin.NewGetLoginRequestParams().WithLoginChallenge(challenge))
|
|
if err != nil {
|
|
h.logger.Warnf("could not get login request for challenge %s: %v", challenge, err)
|
|
|
|
var e *admin.GetLoginRequestGone
|
|
if errors.As(err, &e) {
|
|
w.Header().Set("Location", *e.GetPayload().RedirectTo)
|
|
w.WriteHeader(http.StatusGone)
|
|
|
|
return
|
|
}
|
|
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
h.renderRequestForClientCert(w, r, certEmails, localizer, loginRequest)
|
|
}
|
|
|
|
func (h *LoginHandler) handlePost(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
challenge string,
|
|
certEmails []string,
|
|
localizer *i18n.Localizer,
|
|
) {
|
|
if r.FormValue("use-identity") != "accept" {
|
|
h.rejectLogin(w, challenge, localizer)
|
|
|
|
return
|
|
}
|
|
|
|
// perform certificate auth
|
|
h.logger.Infof("would perform certificate authentication with: %+v", certEmails)
|
|
|
|
userID, err := h.performCertificateLogin(certEmails, r)
|
|
if err != nil {
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
// finish login and redirect to target
|
|
loginRequest, err := h.adminClient.AcceptLoginRequest(
|
|
admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody(
|
|
&models.AcceptLoginRequest{
|
|
Acr: string(ClientCertificate),
|
|
Remember: true,
|
|
RememberFor: 0,
|
|
Subject: &userID,
|
|
}).WithTimeout(TimeoutTen))
|
|
if err != nil {
|
|
h.logger.Errorf("error getting login request: %#v", err)
|
|
|
|
h.fillAcceptLoginRequestErrorBucket(r, err)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Add("Location", *loginRequest.GetPayload().RedirectTo)
|
|
w.WriteHeader(http.StatusFound)
|
|
}
|
|
|
|
func (h *LoginHandler) fillAcceptLoginRequestErrorBucket(r *http.Request, err error) {
|
|
if errorBucket := GetErrorBucket(r); errorBucket != nil {
|
|
var (
|
|
errorDetails *ErrorDetails
|
|
acceptLoginRequestNotFound *admin.AcceptLoginRequestNotFound
|
|
)
|
|
|
|
if errors.As(err, &acceptLoginRequestNotFound) {
|
|
payload := acceptLoginRequestNotFound.GetPayload()
|
|
errorDetails = &ErrorDetails{
|
|
ErrorMessage: payload.Error,
|
|
ErrorDetails: []string{payload.ErrorDescription},
|
|
}
|
|
|
|
if acceptLoginRequestNotFound.Payload.StatusCode != 0 {
|
|
errorDetails.ErrorCode = strconv.Itoa(int(payload.StatusCode))
|
|
}
|
|
} else {
|
|
errorDetails = &ErrorDetails{
|
|
ErrorMessage: "could not accept login",
|
|
ErrorDetails: []string{err.Error()},
|
|
}
|
|
}
|
|
|
|
errorBucket.AddError(errorDetails)
|
|
}
|
|
}
|
|
|
|
func (h *LoginHandler) rejectLogin(w http.ResponseWriter, challenge string, localizer *i18n.Localizer) {
|
|
const Ten = 10 * time.Second
|
|
|
|
rejectLoginRequest, err := h.adminClient.RejectLoginRequest(
|
|
admin.NewRejectLoginRequestParams().WithLoginChallenge(challenge).WithBody(
|
|
&models.RejectRequest{
|
|
ErrorDescription: h.messageCatalog.LookupMessage("LoginDeniedByUser", nil, localizer),
|
|
ErrorHint: h.messageCatalog.LookupMessage("HintChooseAnIdentityForAuthentication", nil, localizer),
|
|
StatusCode: http.StatusForbidden,
|
|
},
|
|
).WithTimeout(Ten))
|
|
if err != nil {
|
|
h.logger.Errorf("error getting reject login request: %#v", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Location", *rejectLoginRequest.GetPayload().RedirectTo)
|
|
w.WriteHeader(http.StatusFound)
|
|
}
|
|
|
|
func (h *LoginHandler) getEmailAddressesFromClientCertificate(r *http.Request) []string {
|
|
if r.TLS != nil && r.TLS.PeerCertificates != nil && len(r.TLS.PeerCertificates) > 0 {
|
|
firstCert := r.TLS.PeerCertificates[0]
|
|
|
|
if !isClientCertificate(firstCert) {
|
|
return nil
|
|
}
|
|
|
|
for _, email := range firstCert.EmailAddresses {
|
|
h.logger.Infof("authenticated with a client certificate for email address %s", email)
|
|
}
|
|
|
|
return firstCert.EmailAddresses
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isClientCertificate(cert *x509.Certificate) bool {
|
|
for _, ext := range cert.ExtKeyUsage {
|
|
if ext == x509.ExtKeyUsageClientAuth {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (h *LoginHandler) renderRequestForClientCert(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
emails []string,
|
|
localizer *i18n.Localizer,
|
|
loginRequest *admin.GetLoginRequestOK,
|
|
) {
|
|
trans := func(label string) string {
|
|
return h.messageCatalog.LookupMessage(label, nil, localizer)
|
|
}
|
|
|
|
rendered := bytes.NewBuffer(make([]byte, 0))
|
|
err := h.templates[CertificateLogin].Lookup("base").Execute(rendered, map[string]interface{}{
|
|
"Title": trans("LoginTitle"),
|
|
csrf.TemplateTag: csrf.TemplateField(r),
|
|
"IntroText": template.HTML(h.messageCatalog.LookupMessage( //nolint:gosec
|
|
"CertLoginIntroText",
|
|
map[string]interface{}{"ClientName": loginRequest.GetPayload().Client.ClientName},
|
|
localizer,
|
|
)),
|
|
"EmailChoiceText": h.messageCatalog.LookupMessagePlural("EmailChoiceText", nil, localizer, len(emails)),
|
|
"emails": emails,
|
|
"RequestText": trans("CertLoginRequestText"),
|
|
"AcceptLabel": trans("LabelAcceptCertLogin"),
|
|
"RejectLabel": trans("LabelRejectCertLogin"),
|
|
})
|
|
|
|
if err != nil {
|
|
h.logger.Error(err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Add("Pragma", "no-cache")
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
_, _ = w.Write(rendered.Bytes())
|
|
}
|
|
|
|
func (h *LoginHandler) performCertificateLogin(emails []string, r *http.Request) (string, error) {
|
|
requestedEmail := r.PostFormValue("email")
|
|
for _, email := range emails {
|
|
if email == requestedEmail {
|
|
return email, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("no user found")
|
|
}
|
|
|
|
func (h *LoginHandler) renderNoEmailsInClientCertificate(w http.ResponseWriter, localizer *i18n.Localizer) {
|
|
trans := func(label string) string {
|
|
return h.messageCatalog.LookupMessage(label, nil, localizer)
|
|
}
|
|
|
|
err := h.templates[NoEmailsInClientCertificate].Lookup("base").Execute(w, map[string]interface{}{
|
|
"Title": trans("NoEmailsInClientCertificateTitle"),
|
|
"Explanation": trans("NoEmailsInClientCertificateExplanation"),
|
|
})
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("template rendering failed")
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
func (h *LoginHandler) renderNoChallengeInRequest(w http.ResponseWriter, localizer *i18n.Localizer) {
|
|
trans := func(label string) string {
|
|
return h.messageCatalog.LookupMessage(label, nil, localizer)
|
|
}
|
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
err := goldmark.Convert([]byte(trans("NoChallengeInRequestExplanation")), buf)
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("markdown conversion failed")
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
err = h.templates[NoChallengeInRequest].Lookup("base").Execute(w, map[string]interface{}{
|
|
"Title": trans("NoChallengeInRequestTitle"),
|
|
"Explanation": template.HTML(buf.String()), //nolint:gosec
|
|
})
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("template rendering failed")
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
func NewLoginHandler(
|
|
logger *log.Logger,
|
|
bundle *i18n.Bundle,
|
|
messageCatalog *services.MessageCatalog,
|
|
adminClient admin.ClientService,
|
|
) *LoginHandler {
|
|
return &LoginHandler{
|
|
adminClient: adminClient,
|
|
bundle: bundle,
|
|
logger: logger,
|
|
templates: map[templateName]*template.Template{
|
|
CertificateLogin: template.Must(template.ParseFS(
|
|
ui.Templates,
|
|
"templates/base.gohtml",
|
|
"templates/client_certificate.gohtml",
|
|
)),
|
|
NoEmailsInClientCertificate: template.Must(template.ParseFS(
|
|
ui.Templates,
|
|
"templates/base.gohtml",
|
|
"templates/no_email_in_client_certificate.gohtml",
|
|
)),
|
|
NoChallengeInRequest: template.Must(template.ParseFS(
|
|
ui.Templates,
|
|
"templates/base.gohtml",
|
|
"templates/no_challenge_in_request.gohtml",
|
|
)),
|
|
},
|
|
messageCatalog: messageCatalog,
|
|
}
|
|
}
|