2020-01-28 19:14:30 +00:00
|
|
|
package server
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"net/http"
|
|
|
|
"net/url"
|
|
|
|
"path"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/dexidp/dex/storage"
|
|
|
|
)
|
|
|
|
|
|
|
|
type deviceCodeResponse struct {
|
2020-10-17 21:54:27 +00:00
|
|
|
// The unique device code for device authentication
|
2020-01-28 19:14:30 +00:00
|
|
|
DeviceCode string `json:"device_code"`
|
2020-10-17 21:54:27 +00:00
|
|
|
// The code the user will exchange via a browser and log in
|
2020-01-28 19:14:30 +00:00
|
|
|
UserCode string `json:"user_code"`
|
2020-10-17 21:54:27 +00:00
|
|
|
// The url to verify the user code.
|
2020-01-28 19:14:30 +00:00
|
|
|
VerificationURI string `json:"verification_uri"`
|
2020-10-17 21:54:27 +00:00
|
|
|
// The verification uri with the user code appended for pre-filling form
|
2020-01-28 19:14:30 +00:00
|
|
|
VerificationURIComplete string `json:"verification_uri_complete"`
|
2020-10-17 21:54:27 +00:00
|
|
|
// The lifetime of the device code
|
2020-01-28 19:14:30 +00:00
|
|
|
ExpireTime int `json:"expires_in"`
|
2020-10-17 21:54:27 +00:00
|
|
|
// How often the device is allowed to poll to verify that the user login occurred
|
2020-01-28 19:14:30 +00:00
|
|
|
PollInterval int `json:"interval"`
|
|
|
|
}
|
|
|
|
|
2020-02-04 15:07:18 +00:00
|
|
|
func (s *Server) getDeviceVerificationURI() string {
|
2020-01-28 19:14:30 +00:00
|
|
|
return path.Join(s.issuerURL.Path, "/device/auth/verify_code")
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) handleDeviceExchange(w http.ResponseWriter, r *http.Request) {
|
|
|
|
switch r.Method {
|
|
|
|
case http.MethodGet:
|
2020-07-14 14:14:37 +00:00
|
|
|
// Grab the parameter(s) from the query.
|
|
|
|
// If "user_code" is set, pre-populate the user code text field.
|
|
|
|
// If "invalid" is set, set the invalidAttempt boolean, which will display a message to the user that they
|
|
|
|
// attempted to redeem an invalid or expired user code.
|
2020-01-28 19:14:30 +00:00
|
|
|
userCode := r.URL.Query().Get("user_code")
|
|
|
|
invalidAttempt, err := strconv.ParseBool(r.URL.Query().Get("invalid"))
|
|
|
|
if err != nil {
|
|
|
|
invalidAttempt = false
|
|
|
|
}
|
2020-02-04 15:07:18 +00:00
|
|
|
if err := s.templates.device(r, w, s.getDeviceVerificationURI(), userCode, invalidAttempt); err != nil {
|
2020-01-28 19:14:30 +00:00
|
|
|
s.logger.Errorf("Server template error: %v", err)
|
2020-02-04 15:07:18 +00:00
|
|
|
s.renderError(r, w, http.StatusNotFound, "Page not found")
|
2020-01-28 19:14:30 +00:00
|
|
|
}
|
|
|
|
default:
|
|
|
|
s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) handleDeviceCode(w http.ResponseWriter, r *http.Request) {
|
|
|
|
pollIntervalSeconds := 5
|
|
|
|
|
|
|
|
switch r.Method {
|
|
|
|
case http.MethodPost:
|
|
|
|
err := r.ParseForm()
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Errorf("Could not parse Device Request body: %v", err)
|
|
|
|
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusNotFound)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Get the client id and scopes from the post
|
2020-01-28 19:14:30 +00:00
|
|
|
clientID := r.Form.Get("client_id")
|
2020-02-04 15:07:18 +00:00
|
|
|
clientSecret := r.Form.Get("client_secret")
|
|
|
|
scopes := strings.Fields(r.Form.Get("scope"))
|
2020-01-28 19:14:30 +00:00
|
|
|
|
|
|
|
s.logger.Infof("Received device request for client %v with scopes %v", clientID, scopes)
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Make device code
|
2020-01-28 19:14:30 +00:00
|
|
|
deviceCode := storage.NewDeviceCode()
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// make user code
|
2021-01-15 15:22:38 +00:00
|
|
|
userCode := storage.NewUserCode()
|
2020-01-28 19:14:30 +00:00
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Generate the expire time
|
2020-01-28 19:14:30 +00:00
|
|
|
expireTime := time.Now().Add(s.deviceRequestsValidFor)
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Store the Device Request
|
2020-01-28 19:14:30 +00:00
|
|
|
deviceReq := storage.DeviceRequest{
|
2020-02-04 15:07:18 +00:00
|
|
|
UserCode: userCode,
|
|
|
|
DeviceCode: deviceCode,
|
|
|
|
ClientID: clientID,
|
|
|
|
ClientSecret: clientSecret,
|
|
|
|
Scopes: scopes,
|
|
|
|
Expiry: expireTime,
|
2020-01-28 19:14:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if err := s.storage.CreateDeviceRequest(deviceReq); err != nil {
|
|
|
|
s.logger.Errorf("Failed to store device request; %v", err)
|
|
|
|
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Store the device token
|
2020-01-28 19:14:30 +00:00
|
|
|
deviceToken := storage.DeviceToken{
|
|
|
|
DeviceCode: deviceCode,
|
|
|
|
Status: deviceTokenPending,
|
|
|
|
Expiry: expireTime,
|
2020-02-04 15:07:18 +00:00
|
|
|
LastRequestTime: s.now(),
|
|
|
|
PollIntervalSeconds: 0,
|
2020-01-28 19:14:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if err := s.storage.CreateDeviceToken(deviceToken); err != nil {
|
|
|
|
s.logger.Errorf("Failed to store device token %v", err)
|
|
|
|
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
u, err := url.Parse(s.issuerURL.String())
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Errorf("Could not parse issuer URL %v", err)
|
2020-02-04 15:07:18 +00:00
|
|
|
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError)
|
2020-01-28 19:14:30 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
u.Path = path.Join(u.Path, "device")
|
|
|
|
vURI := u.String()
|
|
|
|
|
|
|
|
q := u.Query()
|
|
|
|
q.Set("user_code", userCode)
|
|
|
|
u.RawQuery = q.Encode()
|
|
|
|
vURIComplete := u.String()
|
|
|
|
|
|
|
|
code := deviceCodeResponse{
|
|
|
|
DeviceCode: deviceCode,
|
|
|
|
UserCode: userCode,
|
|
|
|
VerificationURI: vURI,
|
|
|
|
VerificationURIComplete: vURIComplete,
|
|
|
|
ExpireTime: int(s.deviceRequestsValidFor.Seconds()),
|
|
|
|
PollInterval: pollIntervalSeconds,
|
|
|
|
}
|
|
|
|
|
2021-01-18 07:10:00 +00:00
|
|
|
// Device Authorization Response can contain cache control header according to
|
|
|
|
// https://tools.ietf.org/html/rfc8628#section-3.2
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
|
|
|
2020-01-28 19:14:30 +00:00
|
|
|
enc := json.NewEncoder(w)
|
2020-02-04 15:07:18 +00:00
|
|
|
enc.SetEscapeHTML(false)
|
2020-01-28 19:14:30 +00:00
|
|
|
enc.SetIndent("", " ")
|
|
|
|
enc.Encode(code)
|
|
|
|
|
|
|
|
default:
|
|
|
|
s.renderError(r, w, http.StatusBadRequest, "Invalid device code request type")
|
|
|
|
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) {
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
switch r.Method {
|
|
|
|
case http.MethodPost:
|
|
|
|
err := r.ParseForm()
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Warnf("Could not parse Device Token Request body: %v", err)
|
|
|
|
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
deviceCode := r.Form.Get("device_code")
|
|
|
|
if deviceCode == "" {
|
|
|
|
s.tokenErrHelper(w, errInvalidRequest, "No device code received", http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
grantType := r.PostFormValue("grant_type")
|
|
|
|
if grantType != grantTypeDeviceCode {
|
|
|
|
s.tokenErrHelper(w, errInvalidGrant, "", http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
now := s.now()
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Grab the device token, check validity
|
2020-01-28 19:14:30 +00:00
|
|
|
deviceToken, err := s.storage.GetDeviceToken(deviceCode)
|
2020-02-04 15:07:18 +00:00
|
|
|
if err != nil {
|
2020-01-28 19:14:30 +00:00
|
|
|
if err != storage.ErrNotFound {
|
|
|
|
s.logger.Errorf("failed to get device code: %v", err)
|
|
|
|
}
|
2020-02-04 15:07:18 +00:00
|
|
|
s.tokenErrHelper(w, errInvalidRequest, "Invalid Device code.", http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
} else if now.After(deviceToken.Expiry) {
|
|
|
|
s.tokenErrHelper(w, deviceTokenExpired, "", http.StatusBadRequest)
|
2020-01-28 19:14:30 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Rate Limiting check
|
2020-02-04 15:07:18 +00:00
|
|
|
slowDown := false
|
2020-01-28 19:14:30 +00:00
|
|
|
pollInterval := deviceToken.PollIntervalSeconds
|
|
|
|
minRequestTime := deviceToken.LastRequestTime.Add(time.Second * time.Duration(pollInterval))
|
|
|
|
if now.Before(minRequestTime) {
|
2020-02-04 15:07:18 +00:00
|
|
|
slowDown = true
|
2020-10-17 21:54:27 +00:00
|
|
|
// Continually increase the poll interval until the user waits the proper time
|
2020-01-28 19:14:30 +00:00
|
|
|
pollInterval += 5
|
|
|
|
} else {
|
|
|
|
pollInterval = 5
|
|
|
|
}
|
|
|
|
|
|
|
|
switch deviceToken.Status {
|
|
|
|
case deviceTokenPending:
|
|
|
|
updater := func(old storage.DeviceToken) (storage.DeviceToken, error) {
|
|
|
|
old.PollIntervalSeconds = pollInterval
|
|
|
|
old.LastRequestTime = now
|
|
|
|
return old, nil
|
|
|
|
}
|
|
|
|
// Update device token last request time in storage
|
|
|
|
if err := s.storage.UpdateDeviceToken(deviceCode, updater); err != nil {
|
|
|
|
s.logger.Errorf("failed to update device token: %v", err)
|
|
|
|
s.renderError(r, w, http.StatusInternalServerError, "")
|
|
|
|
return
|
|
|
|
}
|
2020-02-04 15:07:18 +00:00
|
|
|
if slowDown {
|
|
|
|
s.tokenErrHelper(w, deviceTokenSlowDown, "", http.StatusBadRequest)
|
|
|
|
} else {
|
|
|
|
s.tokenErrHelper(w, deviceTokenPending, "", http.StatusUnauthorized)
|
|
|
|
}
|
2020-01-28 19:14:30 +00:00
|
|
|
case deviceTokenComplete:
|
|
|
|
w.Write([]byte(deviceToken.Token))
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) handleDeviceCallback(w http.ResponseWriter, r *http.Request) {
|
|
|
|
switch r.Method {
|
|
|
|
case http.MethodGet:
|
|
|
|
userCode := r.FormValue("state")
|
|
|
|
code := r.FormValue("code")
|
|
|
|
|
|
|
|
if userCode == "" || code == "" {
|
|
|
|
s.renderError(r, w, http.StatusBadRequest, "Request was missing parameters")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Authorization redirect callback from OAuth2 auth flow.
|
|
|
|
if errMsg := r.FormValue("error"); errMsg != "" {
|
|
|
|
http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
authCode, err := s.storage.GetAuthCode(code)
|
|
|
|
if err != nil || s.now().After(authCode.Expiry) {
|
2020-02-04 15:07:18 +00:00
|
|
|
errCode := http.StatusBadRequest
|
|
|
|
if err != nil && err != storage.ErrNotFound {
|
2020-01-28 19:14:30 +00:00
|
|
|
s.logger.Errorf("failed to get auth code: %v", err)
|
2020-02-04 15:07:18 +00:00
|
|
|
errCode = http.StatusInternalServerError
|
2020-01-28 19:14:30 +00:00
|
|
|
}
|
2020-02-04 15:07:18 +00:00
|
|
|
s.renderError(r, w, errCode, "Invalid or expired auth code.")
|
2020-01-28 19:14:30 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Grab the device request from storage
|
2020-01-28 19:14:30 +00:00
|
|
|
deviceReq, err := s.storage.GetDeviceRequest(userCode)
|
|
|
|
if err != nil || s.now().After(deviceReq.Expiry) {
|
2020-02-04 15:07:18 +00:00
|
|
|
errCode := http.StatusBadRequest
|
|
|
|
if err != nil && err != storage.ErrNotFound {
|
2020-01-28 19:14:30 +00:00
|
|
|
s.logger.Errorf("failed to get device code: %v", err)
|
2020-02-04 15:07:18 +00:00
|
|
|
errCode = http.StatusInternalServerError
|
2020-01-28 19:14:30 +00:00
|
|
|
}
|
2020-02-04 15:07:18 +00:00
|
|
|
s.renderError(r, w, errCode, "Invalid or expired user code.")
|
2020-01-28 19:14:30 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-02-04 15:07:18 +00:00
|
|
|
client, err := s.storage.GetClient(deviceReq.ClientID)
|
2020-01-28 19:14:30 +00:00
|
|
|
if err != nil {
|
2020-02-04 15:07:18 +00:00
|
|
|
if err != storage.ErrNotFound {
|
|
|
|
s.logger.Errorf("failed to get client: %v", err)
|
|
|
|
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
|
|
|
|
} else {
|
|
|
|
s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if client.Secret != deviceReq.ClientSecret {
|
|
|
|
s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
|
2020-01-28 19:14:30 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-02-04 15:07:18 +00:00
|
|
|
resp, err := s.exchangeAuthCode(w, authCode, client)
|
2020-01-28 19:14:30 +00:00
|
|
|
if err != nil {
|
|
|
|
s.logger.Errorf("Could not exchange auth code for client %q: %v", deviceReq.ClientID, err)
|
|
|
|
s.renderError(r, w, http.StatusInternalServerError, "Failed to exchange auth code.")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Grab the device token from storage
|
2020-01-28 19:14:30 +00:00
|
|
|
old, err := s.storage.GetDeviceToken(deviceReq.DeviceCode)
|
|
|
|
if err != nil || s.now().After(old.Expiry) {
|
2020-02-04 15:07:18 +00:00
|
|
|
errCode := http.StatusBadRequest
|
|
|
|
if err != nil && err != storage.ErrNotFound {
|
2020-01-28 19:14:30 +00:00
|
|
|
s.logger.Errorf("failed to get device token: %v", err)
|
2020-02-04 15:07:18 +00:00
|
|
|
errCode = http.StatusInternalServerError
|
2020-01-28 19:14:30 +00:00
|
|
|
}
|
2020-02-04 15:07:18 +00:00
|
|
|
s.renderError(r, w, errCode, "Invalid or expired device code.")
|
2020-01-28 19:14:30 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
updater := func(old storage.DeviceToken) (storage.DeviceToken, error) {
|
|
|
|
if old.Status == deviceTokenComplete {
|
|
|
|
return old, errors.New("device token already complete")
|
|
|
|
}
|
|
|
|
respStr, err := json.MarshalIndent(resp, "", " ")
|
|
|
|
if err != nil {
|
|
|
|
s.logger.Errorf("failed to marshal device token response: %v", err)
|
|
|
|
s.renderError(r, w, http.StatusInternalServerError, "")
|
|
|
|
return old, err
|
|
|
|
}
|
|
|
|
|
|
|
|
old.Token = string(respStr)
|
|
|
|
old.Status = deviceTokenComplete
|
|
|
|
return old, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update refresh token in the storage, store the token and mark as complete
|
|
|
|
if err := s.storage.UpdateDeviceToken(deviceReq.DeviceCode, updater); err != nil {
|
|
|
|
s.logger.Errorf("failed to update device token: %v", err)
|
2020-02-04 15:07:18 +00:00
|
|
|
s.renderError(r, w, http.StatusBadRequest, "")
|
2020-01-28 19:14:30 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-02-04 15:07:18 +00:00
|
|
|
if err := s.templates.deviceSuccess(r, w, client.Name); err != nil {
|
2020-01-28 19:14:30 +00:00
|
|
|
s.logger.Errorf("Server template error: %v", err)
|
2020-02-04 15:07:18 +00:00
|
|
|
s.renderError(r, w, http.StatusNotFound, "Page not found")
|
2020-01-28 19:14:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
default:
|
|
|
|
http.Error(w, fmt.Sprintf("method not implemented: %s", r.Method), http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) verifyUserCode(w http.ResponseWriter, r *http.Request) {
|
|
|
|
switch r.Method {
|
|
|
|
case http.MethodPost:
|
|
|
|
err := r.ParseForm()
|
|
|
|
if err != nil {
|
2020-02-04 15:07:18 +00:00
|
|
|
s.logger.Warnf("Could not parse user code verification request body : %v", err)
|
|
|
|
s.renderError(r, w, http.StatusBadRequest, "")
|
2020-01-28 19:14:30 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
userCode := r.Form.Get("user_code")
|
|
|
|
if userCode == "" {
|
|
|
|
s.renderError(r, w, http.StatusBadRequest, "No user code received")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
userCode = strings.ToUpper(userCode)
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Find the user code in the available requests
|
2020-01-28 19:14:30 +00:00
|
|
|
deviceRequest, err := s.storage.GetDeviceRequest(userCode)
|
|
|
|
if err != nil || s.now().After(deviceRequest.Expiry) {
|
2020-02-04 15:07:18 +00:00
|
|
|
if err != nil && err != storage.ErrNotFound {
|
2020-01-28 19:14:30 +00:00
|
|
|
s.logger.Errorf("failed to get device request: %v", err)
|
|
|
|
}
|
2020-02-04 15:07:18 +00:00
|
|
|
if err := s.templates.device(r, w, s.getDeviceVerificationURI(), userCode, true); err != nil {
|
2020-01-28 19:14:30 +00:00
|
|
|
s.logger.Errorf("Server template error: %v", err)
|
2020-02-04 15:07:18 +00:00
|
|
|
s.renderError(r, w, http.StatusNotFound, "Page not found")
|
2020-01-28 19:14:30 +00:00
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2020-10-17 21:54:27 +00:00
|
|
|
// Redirect to Dex Auth Endpoint
|
2020-01-28 19:14:30 +00:00
|
|
|
authURL := path.Join(s.issuerURL.Path, "/auth")
|
|
|
|
u, err := url.Parse(authURL)
|
|
|
|
if err != nil {
|
|
|
|
s.renderError(r, w, http.StatusInternalServerError, "Invalid auth URI.")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
q := u.Query()
|
|
|
|
q.Set("client_id", deviceRequest.ClientID)
|
2020-02-04 15:07:18 +00:00
|
|
|
q.Set("client_secret", deviceRequest.ClientSecret)
|
2020-01-28 19:14:30 +00:00
|
|
|
q.Set("state", deviceRequest.UserCode)
|
|
|
|
q.Set("response_type", "code")
|
2020-05-13 19:38:43 +00:00
|
|
|
q.Set("redirect_uri", "/device/callback")
|
2020-01-28 19:14:30 +00:00
|
|
|
q.Set("scope", strings.Join(deviceRequest.Scopes, " "))
|
|
|
|
u.RawQuery = q.Encode()
|
|
|
|
|
|
|
|
http.Redirect(w, r, u.String(), http.StatusFound)
|
|
|
|
|
|
|
|
default:
|
|
|
|
s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
|
|
|
|
}
|
|
|
|
}
|