feat: device authorization RFC 8628 (#5646)

* device auth: implement the write events

* add grant type device code

* fix(init): check if default value implements stringer

---------

Co-authored-by: adlerhurst <silvan.reusser@gmail.com>
This commit is contained in:
Tim Möhlmann
2023-04-19 11:46:02 +03:00
committed by GitHub
parent 3cd2cecfdf
commit 5819924275
49 changed files with 2313 additions and 38 deletions

View File

@@ -0,0 +1,201 @@
package login
import (
errs "errors"
"fmt"
"net/http"
"net/url"
"time"
"github.com/gorilla/mux"
"github.com/muhlemmer/gu"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/http/middleware"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
)
const (
tmplDeviceAuthUserCode = "device-usercode"
tmplDeviceAuthAction = "device-action"
)
func (l *Login) renderDeviceAuthUserCode(w http.ResponseWriter, r *http.Request, err error) {
var errID, errMessage string
if err != nil {
logging.WithError(err).Error()
errID, errMessage = l.getErrorMessage(r, err)
}
data := l.getBaseData(r, nil, "DeviceAuth.Title", "DeviceAuth.UserCode.Description", errID, errMessage)
translator := l.getTranslator(r.Context(), nil)
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplDeviceAuthUserCode], data, nil)
}
func (l *Login) renderDeviceAuthAction(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, scopes []string) {
data := &struct {
baseData
AuthRequestID string
Username string
ClientID string
Scopes []string
}{
baseData: l.getBaseData(r, authReq, "DeviceAuth.Title", "DeviceAuth.Action.Description", "", ""),
AuthRequestID: authReq.ID,
Username: authReq.UserName,
ClientID: authReq.ApplicationID,
Scopes: scopes,
}
translator := l.getTranslator(r.Context(), authReq)
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplDeviceAuthAction], data, nil)
}
const (
deviceAuthAllowed = "allowed"
deviceAuthDenied = "denied"
)
// renderDeviceAuthDone renders success.html when the action was allowed and error.html when it was denied.
func (l *Login) renderDeviceAuthDone(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, action string) {
data := &struct {
baseData
Message string
}{
baseData: l.getBaseData(r, authReq, "DeviceAuth.Title", "DeviceAuth.Done.Description", "", ""),
}
translator := l.getTranslator(r.Context(), authReq)
switch action {
case deviceAuthAllowed:
data.Message = translator.LocalizeFromRequest(r, "DeviceAuth.Done.Approved", nil)
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplSuccess], data, nil)
case deviceAuthDenied:
data.ErrMessage = translator.LocalizeFromRequest(r, "DeviceAuth.Done.Denied", nil)
l.renderer.RenderTemplate(w, r, translator, l.renderer.Templates[tmplError], data, nil)
}
}
// handleDeviceUserCode serves the Device Authorization user code submission form.
// The "user_code" may be submitted by URL (GET) or form (POST).
// When a "user_code" is received and found through query,
// handleDeviceAuthUserCode will create a new AuthRequest in the repository.
// The user is then redirected to the /login endpoint to complete authentication.
//
// The agent ID from the context is set to the authentication request
// to ensure the complete login flow is completed from the same browser.
func (l *Login) handleDeviceAuthUserCode(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
err := r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
l.renderDeviceAuthUserCode(w, r, err)
return
}
userCode := r.Form.Get("user_code")
if userCode == "" {
if prompt, _ := url.QueryUnescape(r.Form.Get("prompt")); prompt != "" {
err = errs.New(prompt)
}
l.renderDeviceAuthUserCode(w, r, err)
return
}
deviceAuth, err := l.query.DeviceAuthByUserCode(ctx, userCode)
if err != nil {
l.renderDeviceAuthUserCode(w, r, err)
return
}
userAgentID, ok := middleware.UserAgentIDFromCtx(ctx)
if !ok {
l.renderDeviceAuthUserCode(w, r, errs.New("internal error: agent ID missing"))
return
}
authRequest, err := l.authRepo.CreateAuthRequest(ctx, &domain.AuthRequest{
CreationDate: time.Now(),
AgentID: userAgentID,
ApplicationID: deviceAuth.ClientID,
InstanceID: authz.GetInstance(ctx).InstanceID(),
Request: &domain.AuthRequestDevice{
ID: deviceAuth.AggregateID,
DeviceCode: deviceAuth.DeviceCode,
UserCode: deviceAuth.UserCode,
Scopes: deviceAuth.Scopes,
},
})
if err != nil {
l.renderDeviceAuthUserCode(w, r, err)
return
}
http.Redirect(w, r, l.renderer.pathPrefix+EndpointLogin+"?authRequestID="+authRequest.ID, http.StatusFound)
}
// redirectDeviceAuthStart redirects the user to the start point of
// the device authorization flow. A prompt can be set to inform the user
// of the reason why they are redirected back.
func (l *Login) redirectDeviceAuthStart(w http.ResponseWriter, r *http.Request, prompt string) {
values := make(url.Values)
values.Set("prompt", url.QueryEscape(prompt))
url := url.URL{
Path: l.renderer.pathPrefix + EndpointDeviceAuth,
RawQuery: values.Encode(),
}
http.Redirect(w, r, url.String(), http.StatusSeeOther)
}
// handleDeviceAuthAction is the handler where the user is redirected after login.
// The authRequest is checked if the login was indeed completed.
// When the action of "allowed" or "denied", the device authorization is updated accordingly.
// Else the user is presented with a page where they can choose / submit either action.
func (l *Login) handleDeviceAuthAction(w http.ResponseWriter, r *http.Request) {
authReq, err := l.getAuthRequest(r)
if authReq == nil {
err = errors.ThrowInvalidArgument(err, "LOGIN-OLah8", "invalid or missing auth request")
l.redirectDeviceAuthStart(w, r, err.Error())
return
}
if !authReq.Done() {
l.redirectDeviceAuthStart(w, r, "authentication not completed")
return
}
authDev, ok := authReq.Request.(*domain.AuthRequestDevice)
if !ok {
l.redirectDeviceAuthStart(w, r, fmt.Sprintf("wrong auth request type: %T", authReq.Request))
return
}
action := mux.Vars(r)["action"]
switch action {
case deviceAuthAllowed:
_, err = l.command.ApproveDeviceAuth(r.Context(), authDev.ID, authReq.UserID)
case deviceAuthDenied:
_, err = l.command.CancelDeviceAuth(r.Context(), authDev.ID, domain.DeviceAuthCanceledDenied)
default:
l.renderDeviceAuthAction(w, r, authReq, authDev.Scopes)
return
}
if err != nil {
l.redirectDeviceAuthStart(w, r, err.Error())
return
}
l.renderDeviceAuthDone(w, r, authReq, action)
}
// deviceAuthCallbackURL creates the callback URL with which the user
// is redirected back to the device authorization flow.
func (l *Login) deviceAuthCallbackURL(authRequestID string) string {
return l.renderer.pathPrefix + EndpointDeviceAuthAction + "?authRequestID=" + authRequestID
}
// RedirectDeviceAuthToPrefix allows users to use https://domain.com/device without the /ui/login prefix
// and redirects them to the prefixed endpoint.
// [rfc 8628](https://www.rfc-editor.org/rfc/rfc8628#section-3.2) recommends the URL to be as short as possible.
func RedirectDeviceAuthToPrefix(w http.ResponseWriter, r *http.Request) {
target := gu.PtrCopy(r.URL)
target.Path = HandlerPrefix + EndpointDeviceAuth
http.Redirect(w, r, target.String(), http.StatusFound)
}

View File

@@ -69,6 +69,8 @@ func (l *Login) authRequestCallback(ctx context.Context, authReq *domain.AuthReq
return l.oidcAuthCallbackURL(ctx, authReq.ID), nil
case *domain.AuthRequestSAML:
return l.samlAuthCallbackURL(ctx, authReq.ID), nil
case *domain.AuthRequestDevice:
return l.deviceAuthCallbackURL(authReq.ID), nil
default:
return "", caos_errs.ThrowInternal(nil, "LOGIN-rhjQF", "Errors.AuthRequest.RequestTypeNotSupported")
}

View File

@@ -25,7 +25,8 @@ import (
)
const (
tmplError = "error"
tmplError = "error"
tmplSuccess = "success"
)
type Renderer struct {
@@ -45,6 +46,7 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage
}
tmplMapping := map[string]string{
tmplError: "error.html",
tmplSuccess: "success.html",
tmplLogin: "login.html",
tmplUserSelection: "select_user.html",
tmplPassword: "password.html",
@@ -77,6 +79,8 @@ func CreateRenderer(pathPrefix string, staticDir http.FileSystem, staticStorage
tmplExternalNotFoundOption: "external_not_found_option.html",
tmplLoginSuccess: "login_success.html",
tmplLDAPLogin: "ldap_login.html",
tmplDeviceAuthUserCode: "device_usercode.html",
tmplDeviceAuthAction: "device_action.html",
}
funcs := map[string]interface{}{
"resourceUrl": func(file string) string {
@@ -323,6 +327,7 @@ func (l *Login) chooseNextStep(w http.ResponseWriter, r *http.Request, authReq *
func (l *Login) renderInternalError(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, err error) {
var msg string
if err != nil {
logging.WithError(err).WithField("auth_req_id", authReq.ID).Error()
_, msg = l.getErrorMessage(r, err)
}
data := l.getBaseData(r, authReq, "Errors.Internal", "", "Internal", msg)

View File

@@ -46,6 +46,9 @@ const (
EndpointResources = "/resources"
EndpointDynamicResources = "/resources/dynamic"
EndpointDeviceAuth = "/device"
EndpointDeviceAuthAction = "/device/{action}"
)
var (
@@ -107,5 +110,7 @@ func CreateRouter(login *Login, staticDir http.FileSystem, interceptors ...mux.M
router.HandleFunc(EndpointLDAPLogin, login.handleLDAP).Methods(http.MethodGet)
router.HandleFunc(EndpointLDAPCallback, login.handleLDAPCallback).Methods(http.MethodPost)
router.SkipClean(true).Handle("", http.RedirectHandler(HandlerPrefix+"/", http.StatusMovedPermanently))
router.HandleFunc(EndpointDeviceAuth, login.handleDeviceAuthUserCode).Methods(http.MethodGet, http.MethodPost)
router.HandleFunc(EndpointDeviceAuthAction, login.handleDeviceAuthAction).Methods(http.MethodGet, http.MethodPost)
return router
}

View File

@@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語
Spanish: Español
DeviceAuth:
Title: Geräteautorisierung
UserCode:
Label: Benutzercode
Description: Geben Sie den auf dem Gerät angezeigten Benutzercode ein
ButtonNext: weiter
Action:
Description: Gerätezugriff erlauben
GrantDevice: Sie sind dabei, das Gerät zu erlauben
AccessToScopes: Zugriff auf die folgenden Daten
Button:
Allow: erlauben
Deny: verweigern
Done:
Description: Abgeschlossen
Approved: Gerätezulassung genehmigt. Sie können jetzt zum Gerät zurückkehren.
Denied: Geräteautorisierung verweigert. Sie können jetzt zum Gerät zurückkehren.
Footer:
PoweredBy: Powered By
Tos: AGB
@@ -425,5 +443,7 @@ Errors:
Org:
LoginPolicy:
RegistrationNotAllowed: Registrierung ist nicht erlaubt
DeviceAuth:
NotExisting: Benutzercode existiert nicht
optional: (optional)

View File

@@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語
Spanish: Español
DeviceAuth:
Title: Device Authorization
UserCode:
Label: User Code
Description: Enter the user code presented on the device.
ButtonNext: next
Action:
Description: Grant device access.
GrantDevice: you are about to grant device
AccessToScopes: access to the following scopes
Button:
Allow: allow
Deny: deny
Done:
Description: Done.
Approved: Device authorization approved. You can now return to the device.
Denied: Device authorization denied. You can now return to the device.
Footer:
PoweredBy: Powered By
Tos: TOS
@@ -425,5 +443,7 @@ Errors:
Org:
LoginPolicy:
RegistrationNotAllowed: Registration is not allowed
DeviceAuth:
NotExisting: User Code doesn't exist
optional: (optional)

View File

@@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語
Spanish: Español
DeviceAuth:
Title: Autorisation de l'appareil
UserCode:
Label: Code d'utilisateur
Description: Saisissez le code utilisateur présenté sur l'appareil.
ButtonNext: suivant
Action:
Description: Accordez l'accès à l'appareil.
GrantDevice: vous êtes sur le point d'accorder un appareil
AccessToScopes: accès aux périmètres suivants
Button:
Allow: permettre
Deny: refuser
Done:
Description: Fait.
Approved: Autorisation de l'appareil approuvée. Vous pouvez maintenant retourner à l'appareil.
Denied: Autorisation de l'appareil refusée. Vous pouvez maintenant retourner à l'appareil.
Footer:
PoweredBy: Promulgué par
Tos: TOS
@@ -425,5 +443,7 @@ Errors:
Org:
LoginPolicy:
RegistrationNotAllowed: L'enregistrement n'est pas autorisé
DeviceAuth:
NotExisting: Le code utilisateur n'existe pas
optional: (facultatif)

View File

@@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語
Spanish: Español
DeviceAuth:
Title: Autorizzazione del dispositivo
UserCode:
Label: Codice utente
Description: Inserire il codice utente presentato sul dispositivo.
ButtonNext: prossimo
Action:
Description: Concedi l'accesso al dispositivo.
GrantDevice: stai per concedere il dispositivo
AccessToScopes: accesso ai seguenti ambiti
Button:
Allow: permettere
Deny: negare
Done:
Description: Fatto.
Approved: Autorizzazione del dispositivo approvata. Ora puoi tornare al dispositivo.
Denied: Autorizzazione dispositivo negata. Ora puoi tornare al dispositivo.
Footer:
PoweredBy: Alimentato da
Tos: Termini di servizio
@@ -425,5 +443,7 @@ Errors:
Org:
LoginPolicy:
RegistrationNotAllowed: la registrazione non è consentita.
DeviceAuth:
NotExisting: Il codice utente non esiste
optional: (opzionale)

View File

@@ -309,6 +309,24 @@ ExternalNotFound:
Japanese: 日本語
Spanish: Español
DeviceAuth:
Title: デバイス認証
UserCode:
Label: ユーザーコード
Description: デバイスに表示されたユーザー コードを入力します。
ButtonNext:
Action:
Description: デバイスへのアクセスを許可します。
GrantDevice: デバイスを許可しようとしています
AccessToScopes: 次のスコープへのアクセス
Button:
Allow: 許可する
Deny: 拒否
Done:
Description: 終わり。
Approved: デバイス認証が承認されました。 これで、デバイスに戻ることができます。
Denied: デバイス認証が拒否されました。 これで、デバイスに戻ることができます。
Footer:
PoweredBy: Powered By
Tos: TOS
@@ -385,5 +403,7 @@ Errors:
IAM:
LockoutPolicy:
NotExisting: ロックアウトポリシーが存在しません
DeviceAuth:
NotExisting: ユーザーコードが存在しません
optional: "(オプション)"

View File

@@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語
Spanish: Español
DeviceAuth:
Title: Autoryzacja urządzenia
UserCode:
Label: Kod użytkownika
Description: Wprowadź kod użytkownika prezentowany na urządzeniu.
ButtonNext: Następny
Action:
Description: Przyznaj dostęp do urządzenia.
GrantDevice: zamierzasz przyznać urządzenie
AccessToScopes: dostęp do następujących zakresów
Button:
Allow: umożliwić
Deny: zaprzeczyć
Done:
Description: Zrobione.
Approved: Zatwierdzono autoryzację urządzenia. Możesz teraz wrócić do urządzenia.
Denied: Odmowa autoryzacji urządzenia. Możesz teraz wrócić do urządzenia.
Footer:
PoweredBy: Obsługiwane przez
Tos: TOS
@@ -425,5 +443,7 @@ Errors:
Org:
LoginPolicy:
RegistrationNotAllowed: Rejestracja nie jest dozwolona
DeviceAuth:
NotExisting: Kod użytkownika nie istnieje
optional: (opcjonalny)

View File

@@ -317,6 +317,24 @@ ExternalNotFound:
Japanese: 日本語
Spanish: Español
DeviceAuth:
Title: 设备授权
UserCode:
Label: 用户代码
Description: 输入设备上显示的用户代码。
ButtonNext: 下一个
Action:
Description: 授予设备访问权限。
GrantDevice: 您即将授予设备
AccessToScopes: 访问以下范围
Button:
Allow: 允许
Deny: 否定
Done:
Description: 完毕。
Approved: 设备授权已批准。 您现在可以返回设备。
Denied: 设备授权被拒绝。 您现在可以返回设备。
Footer:
PoweredBy: Powered By
Tos: 服务条款
@@ -425,5 +443,7 @@ Errors:
Org:
LoginPolicy:
RegistrationNotAllowed: 不允许注册
DeviceAuth:
NotExisting: 用户代码不存在
optional: (可选)

View File

@@ -0,0 +1,18 @@
{{template "main-top" .}}
<h1>{{.Title}}</h1>
<p>
{{.Username}}, {{t "DeviceAuth.Action.GrantDevice"}} {{.ClientID}} {{t "DeviceAuth.Action.AccessToScopes"}}: {{.Scopes}}.
</p>
<form method="POST">
{{ .CSRF }}
<input type="hidden" name="authRequestID" value="{{.AuthRequestID}}">
<button class="lgn-raised-button lgn-primary left" type="submit" formaction="./allowed">
{{t "DeviceAuth.Action.Button.Allow"}}
</button>
<button class="lgn-raised-button lgn-warn right" type="submit" formaction="./denied">
{{t "DeviceAuth.Action.Button.Deny"}}
</button>
</form>
{{template "main-bottom" .}}

View File

@@ -0,0 +1,21 @@
{{template "main-top" .}}
<h1>{{.Title}}</h1>
<form method="POST">
{{ .CSRF }}
<div class="fields">
<label class="lgn-label" for="user_code">{{t "DeviceAuth.UserCode.Label"}}</label>
<input class="lgn-input" id="user_code" name="user_code" autofocus required{{if .ErrMessage}} shake{{end}}>
</div>
{{template "error-message" .}}
<div class="lgn-actions">
<span class="fill-space"></span>
<button id="submit-button" class="lgn-raised-button lgn-primary right" type="submit">{{t "DeviceAuth.UserCode.ButtonNext"}}</button>
</div>
</form>
{{template "main-bottom" .}}

View File

@@ -0,0 +1,12 @@
{{template "main-top" .}}
<div class="lgn-head">
<div class="lgn-actions">
<i class="lgn-icon-check-solid lgn-primary"></i>
<p class="lgn-error-message">
{{ .Message }}
</p>
</div>
</div>
{{template "main-bottom" .}}

View File

@@ -1,3 +1,3 @@
package statik
//go:generate statik -src=../static -dest=.. -ns=login
//go:generate statik -f -src=../static -dest=.. -ns=login