mirror of https://github.com/dexidp/dex.git
6 changed files with 650 additions and 0 deletions
@ -0,0 +1,39 @@
|
||||
Authentication through Atlassian Crowd |
||||
|
||||
## Overview |
||||
|
||||
Atlassian Crowd is a centralized identity management solution providing single sign-on and user identity. |
||||
|
||||
Current connector uses request to [Crowd REST API](https://developer.atlassian.com/server/crowd/json-requests-and-responses/) endpoints: |
||||
* `/user` - to get user-info |
||||
* `/session` - to authenticate the user |
||||
|
||||
Offline Access scope support provided with a new request to user authentication and user info endpoints. |
||||
|
||||
## Configuration |
||||
To start using the Atlassian Crowd connector, firstly you need to register an application in your Crowd like specified in the [docs](https://confluence.atlassian.com/crowd/adding-an-application-18579591.html). |
||||
|
||||
The following is an example of a configuration for dex `examples/config-dev.yaml`: |
||||
|
||||
```yaml |
||||
connectors: |
||||
- type: atlassian-crowd |
||||
# Required field for connector id. |
||||
id: crowd |
||||
# Required field for connector name. |
||||
name: Crowd |
||||
config: |
||||
# Required field to connect to Crowd. |
||||
baseURL: https://crowd.example.com/crowd |
||||
# Credentials can be string literals or pulled from the environment. |
||||
clientID: $ATLASSIAN_CROWD_APPLICATION_ID |
||||
clientSecret: $ATLASSIAN_CROWD_CLIENT_SECRET |
||||
# Optional groups whitelist, communicated through the "groups" scope. |
||||
# If `groups` is omitted, all of the user's Crowd groups are returned when the groups scope is present. |
||||
# If `groups` is provided, this acts as a whitelist - only the user's Crowd groups that are in the configured `groups` below will go into the groups claim. |
||||
# Conversely, if the user is not in any of the configured `groups`, the user will not be authenticated. |
||||
groups: |
||||
- my-group |
||||
# Prompt for username field. |
||||
usernamePrompt: Login |
||||
``` |
||||
@ -0,0 +1,437 @@
|
||||
// Package atlassiancrowd provides authentication strategies using Atlassian Crowd.
|
||||
package atlassiancrowd |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"io" |
||||
"io/ioutil" |
||||
"net" |
||||
"net/http" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/dexidp/dex/connector" |
||||
"github.com/dexidp/dex/pkg/groups" |
||||
"github.com/dexidp/dex/pkg/log" |
||||
) |
||||
|
||||
// Config holds configuration options for Atlassian Crowd connector.
|
||||
// Crowd connectors require executing two queries, the first to find
|
||||
// the user based on the username and password given to the connector.
|
||||
// The second to use the user entry to search for groups.
|
||||
//
|
||||
// An example config:
|
||||
//
|
||||
// type: atlassian-crowd
|
||||
// config:
|
||||
// baseURL: https://crowd.example.com/context
|
||||
// clientID: applogin
|
||||
// clientSecret: appP4$$w0rd
|
||||
// # users can be restricted by a list of groups
|
||||
// groups:
|
||||
// - admin
|
||||
// # Prompt for username field
|
||||
// usernamePrompt: Login
|
||||
//
|
||||
type Config struct { |
||||
BaseURL string `json:"baseURL"` |
||||
ClientID string `json:"clientID"` |
||||
ClientSecret string `json:"clientSecret"` |
||||
Groups []string `json:"groups"` |
||||
|
||||
// UsernamePrompt allows users to override the username attribute (displayed
|
||||
// in the username/password prompt). If unset, the handler will use.
|
||||
// "Username".
|
||||
UsernamePrompt string `json:"usernamePrompt"` |
||||
} |
||||
|
||||
type crowdUser struct { |
||||
Key string |
||||
Name string |
||||
Active bool |
||||
Email string |
||||
} |
||||
|
||||
type crowdGroups struct { |
||||
Groups []struct { |
||||
Name string |
||||
} `json:"groups"` |
||||
} |
||||
|
||||
type crowdAuthentication struct { |
||||
Token string |
||||
User struct { |
||||
Name string |
||||
} `json:"user"` |
||||
CreatedDate uint64 `json:"created-date"` |
||||
ExpiryDate uint64 `json:"expiry-date"` |
||||
} |
||||
|
||||
type crowdAuthenticationError struct { |
||||
Reason string |
||||
Message string |
||||
} |
||||
|
||||
// Open returns a strategy for logging in through Atlassian Crowd
|
||||
func (c *Config) Open(_ string, logger log.Logger) (connector.Connector, error) { |
||||
if c.BaseURL == "" { |
||||
return nil, fmt.Errorf("crowd: no baseURL provided for crowd connector") |
||||
} |
||||
return &crowdConnector{Config: *c, logger: logger}, nil |
||||
} |
||||
|
||||
type crowdConnector struct { |
||||
Config |
||||
logger log.Logger |
||||
} |
||||
|
||||
var ( |
||||
_ connector.PasswordConnector = (*crowdConnector)(nil) |
||||
_ connector.RefreshConnector = (*crowdConnector)(nil) |
||||
) |
||||
|
||||
type refreshData struct { |
||||
Username string `json:"username"` |
||||
} |
||||
|
||||
func (c *crowdConnector) Login(ctx context.Context, s connector.Scopes, username, password string) (ident connector.Identity, validPass bool, err error) { |
||||
// make this check to avoid empty passwords.
|
||||
if password == "" { |
||||
return connector.Identity{}, false, nil |
||||
} |
||||
|
||||
// We want to return a different error if the user's password is incorrect vs
|
||||
// if there was an error.
|
||||
incorrectPass := false |
||||
var user crowdUser |
||||
|
||||
client := c.crowdAPIClient() |
||||
|
||||
if incorrectPass, err = c.authenticateWithPassword(ctx, client, username, password); err != nil { |
||||
return connector.Identity{}, false, err |
||||
} |
||||
|
||||
if incorrectPass { |
||||
return connector.Identity{}, false, nil |
||||
} |
||||
|
||||
if user, err = c.user(ctx, client, username); err != nil { |
||||
return connector.Identity{}, false, err |
||||
} |
||||
|
||||
if ident, err = c.identityFromCrowdUser(user); err != nil { |
||||
return connector.Identity{}, false, err |
||||
} |
||||
|
||||
if s.Groups { |
||||
userGroups, err := c.getGroups(ctx, client, s.Groups, ident.Username) |
||||
if err != nil { |
||||
return connector.Identity{}, false, fmt.Errorf("crowd: failed to query groups: %v", err) |
||||
} |
||||
ident.Groups = userGroups |
||||
} |
||||
|
||||
if s.OfflineAccess { |
||||
refresh := refreshData{Username: username} |
||||
// Encode entry for following up requests such as the groups query and refresh attempts.
|
||||
if ident.ConnectorData, err = json.Marshal(refresh); err != nil { |
||||
return connector.Identity{}, false, fmt.Errorf("crowd: marshal refresh data: %v", err) |
||||
} |
||||
} |
||||
|
||||
return ident, true, nil |
||||
} |
||||
|
||||
func (c *crowdConnector) Refresh(ctx context.Context, s connector.Scopes, ident connector.Identity) (connector.Identity, error) { |
||||
var data refreshData |
||||
if err := json.Unmarshal(ident.ConnectorData, &data); err != nil { |
||||
return ident, fmt.Errorf("crowd: failed to unmarshal internal data: %v", err) |
||||
} |
||||
|
||||
var user crowdUser |
||||
client := c.crowdAPIClient() |
||||
|
||||
user, err := c.user(ctx, client, data.Username) |
||||
if err != nil { |
||||
return ident, fmt.Errorf("crowd: get user %q: %v", data.Username, err) |
||||
} |
||||
|
||||
newIdent, err := c.identityFromCrowdUser(user) |
||||
if err != nil { |
||||
return ident, err |
||||
} |
||||
newIdent.ConnectorData = ident.ConnectorData |
||||
|
||||
// If user exists, authenticate it to prolong sso session.
|
||||
err = c.authenticateUser(ctx, client, data.Username) |
||||
if err != nil { |
||||
return ident, fmt.Errorf("crowd: authenticate user: %v", err) |
||||
} |
||||
|
||||
if s.Groups { |
||||
userGroups, err := c.getGroups(ctx, client, s.Groups, newIdent.Username) |
||||
if err != nil { |
||||
return connector.Identity{}, fmt.Errorf("crowd: failed to query groups: %v", err) |
||||
} |
||||
newIdent.Groups = userGroups |
||||
} |
||||
return newIdent, nil |
||||
} |
||||
|
||||
func (c *crowdConnector) Prompt() string { |
||||
return c.UsernamePrompt |
||||
} |
||||
|
||||
func (c *crowdConnector) crowdAPIClient() *http.Client { |
||||
return &http.Client{ |
||||
Transport: &http.Transport{ |
||||
Proxy: http.ProxyFromEnvironment, |
||||
DialContext: (&net.Dialer{ |
||||
Timeout: 30 * time.Second, |
||||
KeepAlive: 30 * time.Second, |
||||
}).DialContext, |
||||
MaxIdleConns: 100, |
||||
IdleConnTimeout: 90 * time.Second, |
||||
TLSHandshakeTimeout: 10 * time.Second, |
||||
ExpectContinueTimeout: 1 * time.Second, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
// authenticateWithPassword creates a new session for user and validates a password with Crowd API
|
||||
func (c *crowdConnector) authenticateWithPassword(ctx context.Context, client *http.Client, username string, password string) (invalidPass bool, err error) { |
||||
req, err := c.crowdUserManagementRequest(ctx, |
||||
"POST", |
||||
"/session", |
||||
struct { |
||||
Username string `json:"username"` |
||||
Password string `json:"password"` |
||||
}{Username: username, Password: password}, |
||||
) |
||||
if err != nil { |
||||
return false, fmt.Errorf("crowd: new auth pass api request %v", err) |
||||
} |
||||
|
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
return false, fmt.Errorf("crowd: api request %v", err) |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
body, err := c.validateCrowdResponse(resp) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
if resp.StatusCode != http.StatusCreated { |
||||
var authError crowdAuthenticationError |
||||
if err := json.Unmarshal(body, &authError); err != nil { |
||||
return false, fmt.Errorf("unmarshal auth pass response: %d %v %q", resp.StatusCode, err, string(body)) |
||||
} |
||||
|
||||
if authError.Reason == "INVALID_USER_AUTHENTICATION" { |
||||
return true, nil |
||||
} |
||||
|
||||
return false, fmt.Errorf("%s: %s", resp.Status, authError.Message) |
||||
} |
||||
|
||||
var authResponse crowdAuthentication |
||||
|
||||
if err := json.Unmarshal(body, &authResponse); err != nil { |
||||
return false, fmt.Errorf("decode auth response: %v", err) |
||||
} |
||||
|
||||
return false, nil |
||||
} |
||||
|
||||
// authenticateUser creates a new session for user without password validations with Crowd API
|
||||
func (c *crowdConnector) authenticateUser(ctx context.Context, client *http.Client, username string) error { |
||||
req, err := c.crowdUserManagementRequest(ctx, |
||||
"POST", |
||||
"/session?validate-password=false", |
||||
struct { |
||||
Username string `json:"username"` |
||||
}{Username: username}, |
||||
) |
||||
if err != nil { |
||||
return fmt.Errorf("crowd: new auth api request %v", err) |
||||
} |
||||
|
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
return fmt.Errorf("crowd: api request %v", err) |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
body, err := c.validateCrowdResponse(resp) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if resp.StatusCode != http.StatusCreated { |
||||
return fmt.Errorf("%s: %s", resp.Status, body) |
||||
} |
||||
|
||||
var authResponse crowdAuthentication |
||||
|
||||
if err := json.Unmarshal(body, &authResponse); err != nil { |
||||
return fmt.Errorf("decode auth response: %v", err) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// user retrieves user info from Crowd API
|
||||
func (c *crowdConnector) user(ctx context.Context, client *http.Client, username string) (crowdUser, error) { |
||||
var user crowdUser |
||||
|
||||
req, err := c.crowdUserManagementRequest(ctx, |
||||
"GET", |
||||
fmt.Sprintf("/user?username=%s", username), |
||||
nil, |
||||
) |
||||
if err != nil { |
||||
return user, fmt.Errorf("crowd: new user api request %v", err) |
||||
} |
||||
|
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
return user, fmt.Errorf("crowd: api request %v", err) |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
body, err := c.validateCrowdResponse(resp) |
||||
if err != nil { |
||||
return user, err |
||||
} |
||||
|
||||
if resp.StatusCode != http.StatusOK { |
||||
return user, fmt.Errorf("%s: %s", resp.Status, body) |
||||
} |
||||
|
||||
if err := json.Unmarshal(body, &user); err != nil { |
||||
return user, fmt.Errorf("failed to decode response: %v", err) |
||||
} |
||||
|
||||
return user, nil |
||||
} |
||||
|
||||
// groups retrieves groups from Crowd API
|
||||
func (c *crowdConnector) groups(ctx context.Context, client *http.Client, username string) (userGroups []string, err error) { |
||||
var crowdGroups crowdGroups |
||||
|
||||
req, err := c.crowdUserManagementRequest(ctx, |
||||
"GET", |
||||
fmt.Sprintf("/user/group/nested?username=%s", username), |
||||
nil, |
||||
) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("crowd: new groups api request %v", err) |
||||
} |
||||
|
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("crowd: api request %v", err) |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
body, err := c.validateCrowdResponse(resp) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if resp.StatusCode != http.StatusOK { |
||||
return nil, fmt.Errorf("%s: %s", resp.Status, body) |
||||
} |
||||
|
||||
if err := json.Unmarshal(body, &crowdGroups); err != nil { |
||||
return nil, fmt.Errorf("failed to decode response: %v", err) |
||||
} |
||||
|
||||
for _, group := range crowdGroups.Groups { |
||||
userGroups = append(userGroups, group.Name) |
||||
} |
||||
|
||||
return userGroups, nil |
||||
} |
||||
|
||||
// identityFromCrowdUser converts crowdUser to Identity
|
||||
func (c *crowdConnector) identityFromCrowdUser(user crowdUser) (connector.Identity, error) { |
||||
identity := connector.Identity{ |
||||
Username: user.Name, |
||||
UserID: user.Key, |
||||
Email: user.Email, |
||||
EmailVerified: true, |
||||
} |
||||
|
||||
return identity, nil |
||||
} |
||||
|
||||
// getGroups retrieves a list of user's groups and filters it
|
||||
func (c *crowdConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) { |
||||
crowdGroups, err := c.groups(ctx, client, userLogin) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if len(c.Groups) > 0 { |
||||
filteredGroups := groups.Filter(crowdGroups, c.Groups) |
||||
if len(filteredGroups) == 0 { |
||||
return nil, fmt.Errorf("crowd: user %q is not in any of the required groups", userLogin) |
||||
} |
||||
return filteredGroups, nil |
||||
} else if groupScope { |
||||
return crowdGroups, nil |
||||
} |
||||
|
||||
return nil, nil |
||||
} |
||||
|
||||
// crowdUserManagementRequest create a http.Request with basic auth, json payload and Accept header
|
||||
func (c *crowdConnector) crowdUserManagementRequest(ctx context.Context, method string, apiURL string, jsonPayload interface{}) (*http.Request, error) { |
||||
var body io.Reader |
||||
if jsonPayload != nil { |
||||
jsonData, err := json.Marshal(jsonPayload) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("crowd: marshal API json payload: %v", err) |
||||
} |
||||
body = bytes.NewReader(jsonData) |
||||
} |
||||
|
||||
req, err := http.NewRequest(method, fmt.Sprintf("%s/rest/usermanagement/1%s", c.BaseURL, apiURL), body) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("new API req: %v", err) |
||||
} |
||||
req = req.WithContext(ctx) |
||||
|
||||
// Crowd API requires a basic auth
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret) |
||||
req.Header.Set("Accept", "application/json") |
||||
if jsonPayload != nil { |
||||
req.Header.Set("Content-type", "application/json") |
||||
} |
||||
return req, nil |
||||
} |
||||
|
||||
// validateCrowdResponse validates unique not JSON responses from API
|
||||
func (c *crowdConnector) validateCrowdResponse(resp *http.Response) ([]byte, error) { |
||||
body, err := ioutil.ReadAll(resp.Body) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("crowd: read user body: %v", err) |
||||
} |
||||
|
||||
if resp.StatusCode == http.StatusForbidden && strings.Contains(string(body), "The server understood the request but refuses to authorize it.") { |
||||
c.logger.Debugf("crowd response validation failed: %s", string(body)) |
||||
return nil, fmt.Errorf("dex is forbidden from making requests to the Atlassian Crowd application by URL %q", c.BaseURL) |
||||
} |
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized && string(body) == "Application failed to authenticate" { |
||||
c.logger.Debugf("crowd response validation failed: %s", string(body)) |
||||
return nil, fmt.Errorf("dex failed to authenticate Crowd Application with ID %q", c.ClientID) |
||||
} |
||||
return body, nil |
||||
} |
||||
@ -0,0 +1,150 @@
|
||||
// Package atlassiancrowd provides authentication strategies using Atlassian Crowd.
|
||||
package atlassiancrowd |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/tls" |
||||
"encoding/json" |
||||
"fmt" |
||||
"io/ioutil" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"reflect" |
||||
"testing" |
||||
|
||||
"github.com/sirupsen/logrus" |
||||
) |
||||
|
||||
func TestUserGroups(t *testing.T) { |
||||
s := newTestServer(map[string]TestServerResponse{ |
||||
"/rest/usermanagement/1/user/group/nested?username=testuser": { |
||||
Body: crowdGroups{Groups: []struct{ Name string }{{Name: "group1"}, {Name: "group2"}}}, |
||||
Code: 200, |
||||
}, |
||||
}) |
||||
defer s.Close() |
||||
|
||||
c := newTestCrowdConnector(s.URL) |
||||
groups, err := c.getGroups(context.Background(), newClient(), true, "testuser") |
||||
|
||||
expectNil(t, err) |
||||
expectEquals(t, groups, []string{"group1", "group2"}) |
||||
} |
||||
|
||||
func TestUserGroupsWithFiltering(t *testing.T) { |
||||
s := newTestServer(map[string]TestServerResponse{ |
||||
"/rest/usermanagement/1/user/group/nested?username=testuser": { |
||||
Body: crowdGroups{Groups: []struct{ Name string }{{Name: "group1"}, {Name: "group2"}}}, |
||||
Code: 200, |
||||
}, |
||||
}) |
||||
defer s.Close() |
||||
|
||||
c := newTestCrowdConnector(s.URL) |
||||
c.Groups = []string{"group1"} |
||||
groups, err := c.getGroups(context.Background(), newClient(), true, "testuser") |
||||
|
||||
expectNil(t, err) |
||||
expectEquals(t, groups, []string{"group1"}) |
||||
} |
||||
|
||||
func TestUserLoginFlow(t *testing.T) { |
||||
s := newTestServer(map[string]TestServerResponse{ |
||||
"/rest/usermanagement/1/session?validate-password=false": { |
||||
Body: crowdAuthentication{}, |
||||
Code: 201, |
||||
}, |
||||
"/rest/usermanagement/1/user?username=testuser": { |
||||
Body: crowdUser{Active: true, Name: "testuser", Email: "testuser@example.com"}, |
||||
Code: 200, |
||||
}, |
||||
"/rest/usermanagement/1/user?username=testuser2": { |
||||
Body: `<html>The server understood the request but refuses to authorize it.</html>`, |
||||
Code: 403, |
||||
}, |
||||
}) |
||||
defer s.Close() |
||||
|
||||
c := newTestCrowdConnector(s.URL) |
||||
user, err := c.user(context.Background(), newClient(), "testuser") |
||||
expectNil(t, err) |
||||
expectEquals(t, user.Name, "testuser") |
||||
expectEquals(t, user.Email, "testuser@example.com") |
||||
|
||||
_, err = c.identityFromCrowdUser(user) |
||||
expectNil(t, err) |
||||
|
||||
err = c.authenticateUser(context.Background(), newClient(), "testuser") |
||||
expectNil(t, err) |
||||
|
||||
_, err = c.user(context.Background(), newClient(), "testuser2") |
||||
expectEquals(t, err, fmt.Errorf("dex is forbidden from making requests to the Atlassian Crowd application by URL %q", s.URL)) |
||||
} |
||||
|
||||
func TestUserPassword(t *testing.T) { |
||||
s := newTestServer(map[string]TestServerResponse{ |
||||
"/rest/usermanagement/1/session": { |
||||
Body: crowdAuthenticationError{Reason: "INVALID_USER_AUTHENTICATION", Message: "test"}, |
||||
Code: 401, |
||||
}, |
||||
"/rest/usermanagement/1/session?validate-password=false": { |
||||
Body: crowdAuthentication{}, |
||||
Code: 201, |
||||
}, |
||||
}) |
||||
defer s.Close() |
||||
|
||||
c := newTestCrowdConnector(s.URL) |
||||
invalidPassword, err := c.authenticateWithPassword(context.Background(), newClient(), "testuser", "testpassword") |
||||
|
||||
expectNil(t, err) |
||||
expectEquals(t, invalidPassword, true) |
||||
|
||||
err = c.authenticateUser(context.Background(), newClient(), "testuser") |
||||
expectNil(t, err) |
||||
} |
||||
|
||||
type TestServerResponse struct { |
||||
Body interface{} |
||||
Code int |
||||
} |
||||
|
||||
func newTestCrowdConnector(baseURL string) crowdConnector { |
||||
connector := crowdConnector{} |
||||
connector.BaseURL = baseURL |
||||
connector.logger = &logrus.Logger{ |
||||
Out: ioutil.Discard, |
||||
Level: logrus.DebugLevel, |
||||
Formatter: &logrus.TextFormatter{DisableColors: true}, |
||||
} |
||||
return connector |
||||
} |
||||
|
||||
func newTestServer(responses map[string]TestServerResponse) *httptest.Server { |
||||
s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
||||
response := responses[r.RequestURI] |
||||
w.Header().Add("Content-Type", "application/json") |
||||
w.WriteHeader(response.Code) |
||||
json.NewEncoder(w).Encode(response.Body) |
||||
})) |
||||
return s |
||||
} |
||||
|
||||
func newClient() *http.Client { |
||||
tr := &http.Transport{ |
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, |
||||
} |
||||
return &http.Client{Transport: tr} |
||||
} |
||||
|
||||
func expectNil(t *testing.T, a interface{}) { |
||||
if a != nil { |
||||
t.Errorf("Expected %+v to equal nil", a) |
||||
} |
||||
} |
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) { |
||||
if !reflect.DeepEqual(a, b) { |
||||
t.Errorf("Expected %+v to equal %+v", a, b) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue