Jan Dittberner
44e18ca3a5
The primary change in this commit is the introduction of consent management. A few minor improvements have been made: - move common header to ui/templates/base.gohtml - add an I18NService to unify localization - add a handlers.getLocalizer function - fix translation extraction and merging in Makefile - add a new AuthMiddleware to centralize client certificate authentication - move client certificate handling to internal/handlers/security.go - improver error handling, allow localization of HTTP error messages
370 lines
10 KiB
Go
370 lines
10 KiB
Go
/*
|
|
Copyright 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 (
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
type IndexHandler struct {
|
|
logger *log.Logger
|
|
trans *services.I18NService
|
|
templates TemplateCache
|
|
}
|
|
|
|
func (h *IndexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
localizer := getLocalizer(h.trans, r)
|
|
|
|
if r.URL.Path != "/" {
|
|
http.Error(w, h.trans.LookupHTTPErrorMessage(http.StatusNotFound, localizer), http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
h.templates.render(h.logger, w, Index, map[string]interface{}{
|
|
"Title": h.trans.LookupMessage("IndexTitle", nil, localizer),
|
|
"WelcomeMessage": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec
|
|
"IndexWelcomeMessage",
|
|
map[string]interface{}{"ManageConsentHRef": "/manage-consent"},
|
|
localizer,
|
|
)),
|
|
})
|
|
}
|
|
|
|
func NewIndex(logger *log.Logger, templateCache TemplateCache, trans *services.I18NService) *IndexHandler {
|
|
return &IndexHandler{logger: logger, trans: trans, templates: templateCache}
|
|
}
|
|
|
|
type ManageConsentHandler struct {
|
|
logger *log.Logger
|
|
trans *services.I18NService
|
|
adminAPI client.OAuth2Api
|
|
templates TemplateCache
|
|
}
|
|
|
|
func (h *ManageConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
localizer := getLocalizer(h.trans, r)
|
|
|
|
allSessions := make(ConsentSessions, 0)
|
|
|
|
var (
|
|
sessions []client.OAuth2ConsentSession
|
|
ok bool
|
|
)
|
|
|
|
for _, s := range GetAuthenticatedAddresses(r) {
|
|
if sessions, ok = h.getConsentSessions(w, r, s); !ok {
|
|
return
|
|
}
|
|
|
|
for _, session := range sessions {
|
|
allSessions = append(allSessions, ConsentSession{Subject: s, Session: session})
|
|
}
|
|
}
|
|
|
|
sort.Sort(allSessions)
|
|
|
|
requestSession, err := GetSession(r)
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("could not get session for request")
|
|
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
h.templates.render(h.logger, w, ManageConsent, map[string]interface{}{
|
|
"Title": h.trans.LookupMessage("ManageConsentTitle", nil, localizer),
|
|
"Description": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec
|
|
"ManageConsentDescription",
|
|
nil,
|
|
localizer,
|
|
)),
|
|
"NoConsentGiven": h.trans.LookupMessage("NoConsentGiven", nil, localizer),
|
|
"Flashes": requestSession.Flashes("messages"),
|
|
"ConsentSessions": allSessions,
|
|
"ButtonTitleRevoke": h.trans.LookupMessage("ButtonTitleRevoke", nil, localizer),
|
|
"ApplicationTitle": h.trans.LookupMessage("ColumnNameApplication", nil, localizer),
|
|
"ActionsTitle": h.trans.LookupMessage("ColumnNameActions", nil, localizer),
|
|
"SubjectTitle": h.trans.LookupMessage("ColumnNameSubject", nil, localizer),
|
|
"GrantedTitle": h.trans.LookupMessage("ColumnNameGranted", nil, localizer),
|
|
"ExpiresTitle": h.trans.LookupMessage("ColumnNameExpires", nil, localizer),
|
|
"LabelUnknown": h.trans.LookupMessage("LabelUnknown", nil, localizer),
|
|
"LabelNever": h.trans.LookupMessage("LabelNever", nil, localizer),
|
|
})
|
|
}
|
|
|
|
type ConsentSession struct {
|
|
Subject string
|
|
Session client.OAuth2ConsentSession
|
|
}
|
|
|
|
func (s ConsentSession) GetClientName() string {
|
|
if !s.Session.HasConsentRequest() {
|
|
return ""
|
|
}
|
|
|
|
request := s.Session.GetConsentRequest()
|
|
if !request.HasClient() {
|
|
return ""
|
|
}
|
|
|
|
consentClient := request.GetClient()
|
|
|
|
return consentClient.GetClientName()
|
|
}
|
|
|
|
func (s ConsentSession) GetID() string {
|
|
if consent, ok := s.Session.GetConsentRequestOk(); ok {
|
|
if app, ok := consent.GetClientOk(); ok {
|
|
return app.GetClientId()
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (s ConsentSession) GrantedAt() *time.Time {
|
|
if grantedAt, ok := s.Session.GetHandledAtOk(); ok {
|
|
return grantedAt
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s ConsentSession) Expires() *time.Time {
|
|
if expiresAt, ok := s.Session.GetExpiresAtOk(); ok {
|
|
if grantedAt := s.GrantedAt(); grantedAt != nil {
|
|
return expiresAt.AccessToken
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type ConsentSessions []ConsentSession
|
|
|
|
func (c ConsentSessions) Len() int {
|
|
return len(c)
|
|
}
|
|
|
|
func (c ConsentSessions) Less(i, j int) bool {
|
|
return c[i].Subject < c[j].Subject && c[i].GetClientName() < c[j].GetClientName()
|
|
}
|
|
|
|
func (c ConsentSessions) Swap(i, j int) {
|
|
c[i], c[j] = c[j], c[i]
|
|
}
|
|
|
|
func (h *ManageConsentHandler) getConsentSessions(
|
|
w http.ResponseWriter, r *http.Request, subject string,
|
|
) ([]client.OAuth2ConsentSession, bool) {
|
|
sessions, response, err := h.adminAPI.ListOAuth2ConsentSessions(r.Context()).Subject(subject).Execute()
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("error getting consent session list")
|
|
|
|
// h.fillAcceptLoginRequestErrorBucket(r, err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
defer func(response *http.Response) { _ = response.Body.Close() }(response)
|
|
|
|
h.logger.WithFields(
|
|
log.Fields{"response": response.Status, "consent_sessions": sessions},
|
|
).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")
|
|
}
|
|
}
|
|
|
|
return sessions, true
|
|
}
|
|
|
|
func NewManageConsent(
|
|
logger *log.Logger, tc TemplateCache, trans *services.I18NService, adminAPI client.OAuth2Api,
|
|
) *ManageConsentHandler {
|
|
return &ManageConsentHandler{logger: logger, trans: trans, adminAPI: adminAPI, templates: tc}
|
|
}
|
|
|
|
type RevokeConsentHandler struct {
|
|
logger *log.Logger
|
|
trans *services.I18NService
|
|
adminAPI client.OAuth2Api
|
|
templates TemplateCache
|
|
}
|
|
|
|
func (h *RevokeConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
localizer := getLocalizer(h.trans, r)
|
|
|
|
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
|
http.Error(
|
|
w, h.trans.LookupHTTPErrorMessage(http.StatusMethodNotAllowed, localizer), http.StatusMethodNotAllowed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
clientID, _ := strings.CutPrefix(r.URL.Path, "/revoke-consent/")
|
|
subject := r.URL.Query().Get("subject")
|
|
|
|
if clientID == "" || subject == "" {
|
|
http.Error(w, h.trans.LookupHTTPErrorMessage(http.StatusNotFound, localizer), http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodGet {
|
|
h.handleGet(w, r, clientID, localizer, subject)
|
|
|
|
return
|
|
}
|
|
|
|
h.handlePost(w, r, clientID, subject, localizer)
|
|
}
|
|
|
|
func (h *RevokeConsentHandler) handleGet(
|
|
w http.ResponseWriter, r *http.Request, clientID string, localizer *i18n.Localizer, subject string,
|
|
) {
|
|
clientApp, found, err := h.getClient(r.Context(), clientID)
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("could not get client")
|
|
|
|
http.Error(
|
|
w, h.trans.LookupHTTPErrorMessage(http.StatusInternalServerError, localizer),
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if !found {
|
|
http.Error(w, h.trans.LookupHTTPErrorMessage(http.StatusNotFound, localizer), http.StatusNotFound)
|
|
|
|
return
|
|
}
|
|
|
|
h.templates.render(h.logger, w, ConfirmRevoke, map[string]interface{}{
|
|
"Title": h.trans.LookupMessage("ConfirmRevokeTitle", nil, localizer),
|
|
"ButtonTitleRevoke": h.trans.LookupMessage("ButtonTitleConfirmRevoke", nil, localizer),
|
|
"ButtonTitleCancel": h.trans.LookupMessage("ButtonTitleCancel", nil, localizer),
|
|
"CancelLink": "/manage-consent",
|
|
csrf.TemplateTag: csrf.TemplateField(r),
|
|
"Explanation": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec
|
|
"ConfirmRevokeExplanation", map[string]interface{}{
|
|
"ApplicationID": template.URLQueryEscaper(clientApp.GetClientId()),
|
|
"Application": template.HTMLEscapeString(clientApp.GetClientName()),
|
|
"Subject": subject,
|
|
}, localizer)),
|
|
})
|
|
}
|
|
|
|
func (h *RevokeConsentHandler) handlePost(
|
|
w http.ResponseWriter, r *http.Request, clientID string, subject string, localizer *i18n.Localizer,
|
|
) {
|
|
err := h.revokeConsent(r.Context(), clientID, subject)
|
|
if err != nil {
|
|
h.logger.WithError(err).Error("could not revoke consent")
|
|
|
|
http.Error(
|
|
w, h.trans.LookupHTTPErrorMessage(http.StatusInternalServerError, localizer),
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/manage-consent", http.StatusFound)
|
|
}
|
|
|
|
func (h *RevokeConsentHandler) getClient(ctx context.Context, clientID string) (*client.OAuth2Client, bool, error) {
|
|
clientApp, response, err := h.adminAPI.GetOAuth2Client(ctx, clientID).Execute()
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("error getting client application: %w", err)
|
|
}
|
|
|
|
defer func(response *http.Response) { _ = response.Body.Close() }(response)
|
|
|
|
h.logger.WithFields(
|
|
log.Fields{"response": response.Status, "client_app": clientApp},
|
|
).Debug("got response for GetOAuth2Client")
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
return clientApp, true, nil
|
|
}
|
|
|
|
func (h *RevokeConsentHandler) revokeConsent(ctx context.Context, clientID, subject string) error {
|
|
response, err := h.adminAPI.RevokeOAuth2ConsentSessions(ctx).Client(clientID).Subject(subject).Execute()
|
|
if err != nil {
|
|
return fmt.Errorf("could not revoke consent: %w", err)
|
|
}
|
|
|
|
defer func(response *http.Response) { _ = response.Body.Close() }(response)
|
|
|
|
h.logger.WithFields(
|
|
log.Fields{"response": response.Status},
|
|
).Debug("got response for RevokeOAuth2ConsentSessions")
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func NewRevokeConsent(
|
|
logger *log.Logger, tc TemplateCache, trans *services.I18NService, adminAPI client.OAuth2Api,
|
|
) *RevokeConsentHandler {
|
|
return &RevokeConsentHandler{logger: logger, trans: trans, adminAPI: adminAPI, templates: tc}
|
|
}
|