Jan Dittberner
679dcb27ce
- use new SDK package - add session to transport user information from login to consent
436 lines
12 KiB
Go
436 lines
12 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"
|
|
"context"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/gorilla/csrf"
|
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
|
client "github.com/ory/hydra-client-go/v2"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"code.cacert.org/cacert/oidc-idp/internal/services"
|
|
"code.cacert.org/cacert/oidc-idp/ui"
|
|
)
|
|
|
|
type acrType string
|
|
|
|
const (
|
|
// ClientCertificate represents a client certificate login
|
|
ClientCertificate acrType = "urn:cacert:1fa:cert"
|
|
// ClientCertificateOTP acrType = "urn:cacert:2fa:cert+otp"
|
|
// ClientCertificateToken acrType = "urn:cacert:2fa:cert+token"
|
|
)
|
|
|
|
type contextKey int
|
|
|
|
const (
|
|
ctxKeyMessage contextKey = iota
|
|
)
|
|
|
|
type templateName string
|
|
|
|
const (
|
|
CertificateLogin templateName = "cert"
|
|
NoEmailsInClientCertificate templateName = "no_emails"
|
|
NoChallengeInRequest templateName = "no_challenge"
|
|
)
|
|
|
|
type LoginHandler struct {
|
|
adminClient client.OAuth2Api
|
|
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.WithField("challenge", challenge).Debug("received login challenge")
|
|
|
|
certFullName, certEmails := h.getDataFromClientCert(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, certFullName, certEmails, localizer)
|
|
}
|
|
}
|
|
|
|
func (h *LoginHandler) handleGet(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
challenge string,
|
|
certEmails []string,
|
|
localizer *i18n.Localizer,
|
|
) {
|
|
oAuth2LoginRequest, response, err := h.adminClient.GetOAuth2LoginRequest(
|
|
r.Context(),
|
|
).LoginChallenge(challenge).Execute()
|
|
if err != nil {
|
|
h.logger.WithError(err).WithField(
|
|
"challenge", challenge,
|
|
).Warn("could not get login request for challenge")
|
|
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
h.logger.WithFields(
|
|
log.Fields{"response": response.Status, "login_request": oAuth2LoginRequest},
|
|
).Debug("got response for GetOAuth2LoginRequest")
|
|
|
|
h.renderRequestForClientCert(w, r, certEmails, localizer, oAuth2LoginRequest)
|
|
}
|
|
|
|
type FlashMessage struct {
|
|
Type string
|
|
Message string
|
|
}
|
|
|
|
func (h *LoginHandler) handlePost(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
challenge string,
|
|
certFullName string,
|
|
certEmails []string,
|
|
localizer *i18n.Localizer,
|
|
) {
|
|
if r.FormValue("use-identity") != "accept" {
|
|
h.rejectLogin(w, r, challenge, localizer)
|
|
|
|
return
|
|
}
|
|
|
|
if r.FormValue("email") == "" {
|
|
h.handleGet(w, r.WithContext(context.WithValue(
|
|
r.Context(),
|
|
ctxKeyMessage,
|
|
FlashMessage{
|
|
Type: "warning",
|
|
Message: h.messageCatalog.LookupMessage("NoEmailAddressSelected", nil, localizer),
|
|
},
|
|
)), challenge, certEmails, localizer)
|
|
|
|
return
|
|
}
|
|
|
|
// perform certificate auth
|
|
h.logger.WithFields(log.Fields{
|
|
"emails": certEmails,
|
|
"full_name": certFullName,
|
|
}).Info("will perform certificate authentication")
|
|
|
|
userID, err := h.performCertificateLogin(certEmails, r)
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("could not perform certificate login")
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
session, err := GetSession(r)
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("could not perform certificate login")
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
session.Values[services.SessionFullName] = certFullName
|
|
session.Values[services.SessionEmail] = userID
|
|
session.Options.HttpOnly = true
|
|
session.Options.Secure = true
|
|
|
|
if err = session.Save(r, w); err != nil {
|
|
h.logger.WithError(err).Error("could not save session")
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
// finish login and redirect to target
|
|
acceptRequest := client.NewAcceptOAuth2LoginRequest(userID)
|
|
acceptRequest.SetRemember(true)
|
|
acceptRequest.SetRememberFor(0)
|
|
acceptRequest.SetAcr(string(ClientCertificate))
|
|
|
|
loginRequest, response, err := h.adminClient.AcceptOAuth2LoginRequest(
|
|
r.Context(),
|
|
).LoginChallenge(challenge).AcceptOAuth2LoginRequest(*acceptRequest).Execute()
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("error getting login request")
|
|
|
|
// h.fillAcceptLoginRequestErrorBucket(r, err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
h.logger.WithFields(
|
|
log.Fields{"response": response.Status, "accept_login_request": loginRequest},
|
|
).Debug("got response for AcceptOAuth2LoginRequest")
|
|
|
|
if h.logger.IsLevelEnabled(log.TraceLevel) {
|
|
if rb, err := io.ReadAll(response.Body); err == nil {
|
|
h.logger.WithField("response_body", rb).Trace("response body from Hydra")
|
|
}
|
|
}
|
|
|
|
w.Header().Add("Location", loginRequest.GetRedirectTo())
|
|
w.WriteHeader(http.StatusFound)
|
|
}
|
|
|
|
/*
|
|
func (h *LoginHandler) fillAcceptLoginRequestErrorBucket(r *http.Request, err error) {
|
|
if errorBucket := GetErrorBucket(r); errorBucket != nil {
|
|
var (
|
|
errorDetails *ErrorDetails
|
|
acceptLoginRequestNotFound *client.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,
|
|
r *http.Request,
|
|
challenge string,
|
|
localizer *i18n.Localizer,
|
|
) {
|
|
rejectRequest := client.NewRejectOAuth2RequestWithDefaults()
|
|
rejectRequest.SetErrorDescription(h.messageCatalog.LookupMessage("LoginDeniedByUser", nil, localizer))
|
|
rejectRequest.SetErrorHint(h.messageCatalog.LookupMessage("HintChooseAnIdentityForAuthentication", nil, localizer))
|
|
rejectRequest.SetStatusCode(http.StatusForbidden)
|
|
|
|
rejectLoginRequest, response, err := h.adminClient.RejectOAuth2LoginRequest(
|
|
r.Context(),
|
|
).LoginChallenge(challenge).RejectOAuth2Request(*rejectRequest).Execute()
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("error getting reject login request")
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
h.logger.WithFields(
|
|
log.Fields{"response": response.Status, "reject_login_request": rejectLoginRequest},
|
|
).Debug("go response for RejectOAuth2LoginRequest")
|
|
|
|
w.Header().Set("Location", rejectLoginRequest.GetRedirectTo())
|
|
w.WriteHeader(http.StatusFound)
|
|
}
|
|
|
|
func (h *LoginHandler) getDataFromClientCert(r *http.Request) (string, []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.WithField(
|
|
"email", email,
|
|
).Info("authenticated with a client certificate for email address")
|
|
}
|
|
|
|
return firstCert.Subject.CommonName, 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 *client.OAuth2LoginRequest,
|
|
) {
|
|
msg := h.messageCatalog.LookupMessage
|
|
msgPlural := h.messageCatalog.LookupMessagePlural
|
|
msgMarkdown := h.messageCatalog.LookupMarkdownMessage
|
|
|
|
rendered := bytes.NewBuffer(make([]byte, 0))
|
|
|
|
err := h.templates[CertificateLogin].Lookup("base").Execute(rendered, map[string]interface{}{
|
|
"Title": msg("LoginTitle", nil, localizer),
|
|
csrf.TemplateTag: csrf.TemplateField(r),
|
|
"IntroText": template.HTML(msgMarkdown( //nolint:gosec
|
|
"CertLoginIntroText",
|
|
map[string]interface{}{"ClientName": loginRequest.Client.ClientName},
|
|
localizer,
|
|
)),
|
|
"EmailChoiceText": msgPlural("EmailChoiceText", nil, localizer, len(emails)),
|
|
"emails": emails,
|
|
"RequestText": msg("CertLoginRequestText", nil, localizer),
|
|
"AcceptLabel": msg("LabelAcceptCertLogin", nil, localizer),
|
|
"RejectLabel": msg("LabelRejectCertLogin", nil, localizer),
|
|
"FlashMessage": r.Context().Value(ctxKeyMessage),
|
|
})
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("template rendering failed")
|
|
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) {
|
|
msg := h.messageCatalog.LookupMessage
|
|
|
|
err := h.templates[NoEmailsInClientCertificate].Lookup("base").Execute(w, map[string]interface{}{
|
|
"Title": msg("NoEmailsInClientCertificateTitle", nil, localizer),
|
|
"Explanation": msg("NoEmailsInClientCertificateExplanation", nil, localizer),
|
|
})
|
|
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) {
|
|
err := h.templates[NoChallengeInRequest].Lookup("base").Execute(w, map[string]interface{}{
|
|
"Title": h.messageCatalog.LookupMessage("NoChallengeInRequestTitle", nil, localizer),
|
|
"Explanation": template.HTML(h.messageCatalog.LookupMarkdownMessage( //nolint:gosec
|
|
"NoChallengeInRequestExplanation",
|
|
nil,
|
|
localizer,
|
|
)),
|
|
})
|
|
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 client.OAuth2Api,
|
|
) *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,
|
|
}
|
|
}
|