diff --git a/corporal/connector/interface.go b/corporal/connector/interface.go index 33023b4..47ffc63 100644 --- a/corporal/connector/interface.go +++ b/corporal/connector/interface.go @@ -14,7 +14,7 @@ type MatrixConnector interface { DetermineCurrentState(ctx *AccessTokenContext, managedUserIds []string, adminUserId string) (*CurrentState, error) - EnsureUserAccountExists(userId string) error + EnsureUserAccountExists(userId, password string) error GetUserProfileByUserId(ctx *AccessTokenContext, userId string) (*matrix.ApiUserProfileResponse, error) SetUserDisplayName(ctx *AccessTokenContext, userId string, displayName string) error diff --git a/corporal/connector/synapse.go b/corporal/connector/synapse.go index 6d34ef0..fa1d69e 100644 --- a/corporal/connector/synapse.go +++ b/corporal/connector/synapse.go @@ -98,7 +98,7 @@ func (me *SynapseConnector) DetermineCurrentState( return connectorState, nil } -func (me *SynapseConnector) EnsureUserAccountExists(userId string) error { +func (me *SynapseConnector) EnsureUserAccountExists(userId, password string) error { userIdLocalPart, err := gomatrix.ExtractUserLocalpart(userId) if err != nil { return err @@ -124,22 +124,6 @@ func (me *SynapseConnector) EnsureUserAccountExists(userId string) error { return err } - // We create users with random passwords. - // Those passwords are never meant to be given out or used. - // - // Whenever we need to authenticate, we can just obtain an access token - // thanks to shared-secret-auth, regardless of the database password. - // (see ObtainNewAccessTokenForUserId) - // - // Whenever users need to log in, we intercept the /login API - // and possibly turn the call into a request that shared-secret-auth understands - // (see LoginInterceptor). - passwordBytes, err := util.GenerateRandomBytes(64) - if err != nil { - return err - } - password := fmt.Sprintf("%x", passwordBytes) - // Generating the HMAC the same way that the `register_new_matrix_user` script from Matrix Synapse does it. mac := hmac.New(sha1.New, []byte(me.registrationSharedSecret)) mac.Write([]byte(nonceResponse.Nonce)) diff --git a/corporal/httpgateway/interceptor_login.go b/corporal/httpgateway/interceptor_login.go index 7116b8a..4a191b8 100644 --- a/corporal/httpgateway/interceptor_login.go +++ b/corporal/httpgateway/interceptor_login.go @@ -108,12 +108,12 @@ func (me *LoginInterceptor) Intercept(r *http.Request) InterceptorResponse { return createInterceptorErrorResponse(loggingContextFields, matrix.ErrorForbidden, "Rejecting non-own domains") } - policy := me.policyStore.Get() - if policy == nil { + policyObj := me.policyStore.Get() + if policyObj == nil { return createInterceptorErrorResponse(loggingContextFields, matrix.ErrorUnknown, "Missing policy") } - userPolicy := policy.GetUserPolicyByUserId(userIdFull) + userPolicy := policyObj.GetUserPolicyByUserId(userIdFull) if userPolicy == nil { // Not a user we manage. // Let it go through and let the upstream server's policies apply, whatever they may be. @@ -127,6 +127,19 @@ func (me *LoginInterceptor) Intercept(r *http.Request) InterceptorResponse { return createInterceptorErrorResponse(loggingContextFields, matrix.ErrorUserDeactivated, "Deactivated in policy") } + if userPolicy.AuthType == policy.UserAuthTypePassthrough { + // UserAuthTypePassthrough is a special AuthType, authentication for which is not meant to be handled by us. + // Users are created with an initial password as defined in userPolicy.AuthCredential, + // but password-management is then potentially left to the homeserver (depending on policyObj.Flags.AllowCustomPassthroughUserPasswords). + // Authentication always happens at the homeserver. + return InterceptorResponse{ + Result: InterceptorResultProxy, + LoggingContextFields: loggingContextFields, + } + } + + // Authentication for all other auth types is handled by us (below) + loggingContextFields["authType"] = userPolicy.AuthType isAuthenticated, err := me.userAuthChecker.Check( diff --git a/corporal/httpgateway/policycheck/user.go b/corporal/httpgateway/policycheck/user.go index c27a3cf..80fda33 100644 --- a/corporal/httpgateway/policycheck/user.go +++ b/corporal/httpgateway/policycheck/user.go @@ -28,10 +28,10 @@ func CheckUserDeactivate(r *http.Request, ctx context.Context, policy policy.Pol } // CheckUserSetPassword is a policy checker for: /_matrix/client/r0/account/password -func CheckUserSetPassword(r *http.Request, ctx context.Context, policy policy.Policy, checker policy.Checker) PolicyCheckResponse { +func CheckUserSetPassword(r *http.Request, ctx context.Context, policyObj policy.Policy, checker policy.Checker) PolicyCheckResponse { userId := ctx.Value("userId").(string) - userPolicy := policy.GetUserPolicyByUserId(userId) + userPolicy := policyObj.GetUserPolicyByUserId(userId) if userPolicy == nil { // Not a user we manage. // Let it go through and let the upstream server's policies apply, whatever they may be. @@ -40,9 +40,23 @@ func CheckUserSetPassword(r *http.Request, ctx context.Context, policy policy.Po } } + if userPolicy.AuthType == policy.UserAuthTypePassthrough { + if policyObj.Flags.AllowCustomPassthroughUserPasswords { + return PolicyCheckResponse{ + Allow: true, + } + } + + return PolicyCheckResponse{ + Allow: false, + ErrorCode: matrix.ErrorForbidden, + ErrorMessage: "Denied: passthrough user, but policy does not allow changes", + } + } + return PolicyCheckResponse{ Allow: false, ErrorCode: matrix.ErrorForbidden, - ErrorMessage: "Denied", + ErrorMessage: "Denied: non-passthrough users are always authenticated against matrix-corporal", } } diff --git a/corporal/policy/policy.go b/corporal/policy/policy.go index 89fff0b..8f48992 100644 --- a/corporal/policy/policy.go +++ b/corporal/policy/policy.go @@ -30,24 +30,49 @@ func (me *Policy) GetUserPolicyByUserId(userId string) *UserPolicy { } type PolicyFlags struct { - // Tells whether users are allowed to have display names, + // AllowCustomUserDisplayNames tells whether users are allowed to have display names, // which deviate from the ones in the policy. AllowCustomUserDisplayNames bool `json:"allowCustomUserDisplayNames"` - // Tells whether users are allowed to have avatars, + // AllowCustomUserAvatars tells whether users are allowed to have avatars, // which deviate from the ones in the policy. AllowCustomUserAvatars bool `json:"allowCustomUserAvatars"` - // Tells whether users are forbidden from creating rooms. + // AllowCustomPassthroughUserPasswords tells if managed users of AuthType=UserAuthTypePassthrough can change their password. + // This is possible, because their password is stored and managed on the actual homeserver. + // We can let password-changing requests go through. + // + // Users with another AuthType cannot change their password, because authentication happens on our side, + // against the AuthCredential specified in the user's policy. + AllowCustomPassthroughUserPasswords bool `json:"allowCustomPassthroughUserPasswords"` + + // ForbidRoomCreation tells whether users are forbidden from creating rooms. // When there's a dedicated `UserPolicy` for the user, that one takes precedence over this default. ForbidRoomCreation bool `json:"forbidRoomCreation"` } +const ( + UserAuthTypePassthrough = "passthrough" + UserAuthTypeMd5 = "md5" + UserAuthTypeSha1 = "sha1" + UserAuthTypeSha256 = "sha256" + UserAuthTypeSha512 = "sha512" +) + type UserPolicy struct { Id string `json:"id"` Active bool `json:"active"` - AuthType string `json:"authType"` + // AuthType's value is supposed to be one the `UserAuthType*` constants + AuthType string `json:"authType"` + + // AuthCredential holds the up-to-date password for all auth types (other than UserAuthTypePassthrough). + // + // If AuthType is NOT UserAuthTypePassthrough, we intercept login requests and authenticate users against this value. + // + // If AuthType is UserAuthTypePassthrough, AuthCredential only serves as the initial password for the user account. + // In such cases, authentication is performed by the homeserver, not by us. + // Subsequent changes to AuthCredential (after the user account has been created) are not reflected. AuthCredential string `json:"authCredential"` DisplayName string `json:"displayName"` diff --git a/corporal/reconciliation/computator/computator.go b/corporal/reconciliation/computator/computator.go index c760a39..b17b53d 100644 --- a/corporal/reconciliation/computator/computator.go +++ b/corporal/reconciliation/computator/computator.go @@ -6,6 +6,7 @@ import ( "devture-matrix-corporal/corporal/policy" "devture-matrix-corporal/corporal/reconciliation" "devture-matrix-corporal/corporal/util" + "fmt" "github.com/sirupsen/logrus" ) @@ -95,7 +96,8 @@ func (me *ReconciliationStateComputator) computeUserActivationChanges( actions = append(actions, &reconciliation.StateAction{ Type: reconciliation.ActionUserCreate, Payload: map[string]interface{}{ - "userId": userPolicy.Id, + "userId": userPolicy.Id, + "password": me.generateInitialPasswordForUser(*userPolicy), }, }) } @@ -378,3 +380,31 @@ func (me *ReconciliationStateComputator) computeUserRoomChanges( return actions } + +func (me *ReconciliationStateComputator) generateInitialPasswordForUser(userPolicy policy.UserPolicy) string { + // UserAuthTypePassthrough is a special AuthType. Users are created with an initial password as specified in the policy. + // For such users, authentication is delegated to the homeserver. + // We can do password matching on our side as well (at least initially), but delegating authentication to the homeserver, + // allows users to change their password there, etc. + // The actual password on the homeserver may change over time. + if userPolicy.AuthType == policy.UserAuthTypePassthrough { + return userPolicy.AuthCredential + } + + // Some other auth type. We create such users with a random password. + // These passwords are never meant to be given out or used. + // + // Whenever we need to authenticate, we can just obtain an access token + // thanks to shared-secret-auth, regardless of the actual password that the user has been created with on the homeserver. + // (see ObtainNewAccessTokenForUserId) + // + // Whenever users need to log in, we intercept the /login API + // and possibly turn the call into a request that shared-secret-auth understands + // (see LoginInterceptor). + + passwordBytes, err := util.GenerateRandomBytes(64) + if err != nil { + panic(err) + } + return fmt.Sprintf("%x", passwordBytes) +} diff --git a/corporal/reconciliation/computator/computator_test.go b/corporal/reconciliation/computator/computator_test.go index 18a4085..738c53c 100644 --- a/corporal/reconciliation/computator/computator_test.go +++ b/corporal/reconciliation/computator/computator_test.go @@ -127,5 +127,32 @@ func determineUserActionMismatchError(expected, computed *reconciliation.StateAc ) } + if expected.Type == reconciliation.ActionUserCreate { + passwordComputed, err := computed.GetStringPayloadDataByKey("password") + if err != nil { + return fmt.Errorf("Did not expect computed %s action to not have a password payload: %s", reconciliation.ActionUserCreate, err) + } + + passwordExpected, err := expected.GetStringPayloadDataByKey("password") + if err != nil { + return fmt.Errorf("Did not expect expected %s action to not have a password payload: %s", reconciliation.ActionUserCreate, err) + } + + if passwordExpected == "__RANDOM__" { + if len(passwordComputed) != 128 { + return fmt.Errorf("Expected a randomly-generated 128-character password, got: %s", passwordComputed) + } + return nil + } + + if passwordExpected != passwordComputed { + return fmt.Errorf("Expected password %s, got %s", passwordExpected, passwordComputed) + } + + return nil + } + + // TODO - we can validate other actions in more detail + return nil } diff --git a/corporal/reconciliation/computator/testdata/01-missing-user-is-created.json b/corporal/reconciliation/computator/testdata/01-missing-user-is-created.json index c75dc5e..63faa0a 100644 --- a/corporal/reconciliation/computator/testdata/01-missing-user-is-created.json +++ b/corporal/reconciliation/computator/testdata/01-missing-user-is-created.json @@ -23,9 +23,19 @@ "users": [ { "id": "@a:host", + "authType": "plain", + "authCredential": "test", "active": true, "joinedCommunityIds": ["+a:host"], "joinedRoomIds": ["!a:host"] + }, + { + "id": "@b:host", + "authType": "sha1", + "authCredential": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "active": true, + "joinedCommunityIds": [], + "joinedRoomIds": [] } ] }, @@ -35,10 +45,10 @@ { "type": "user.create", "payload": { - "userId": "@a:host" + "userId": "@a:host", + "password": "__RANDOM__" } }, - { "type": "community.join", "payload": { @@ -46,13 +56,20 @@ "communityId": "+a:host" } }, - { "type": "room.join", "payload": { "userId": "@a:host", "roomId": "!a:host" } + }, + + { + "type": "user.create", + "payload": { + "userId": "@b:host", + "password": "__RANDOM__" + } } ] } diff --git a/corporal/reconciliation/computator/testdata/02-more-complicated.json b/corporal/reconciliation/computator/testdata/02-more-complicated.json index 6908f9d..ccb39f1 100644 --- a/corporal/reconciliation/computator/testdata/02-more-complicated.json +++ b/corporal/reconciliation/computator/testdata/02-more-complicated.json @@ -92,7 +92,8 @@ { "type": "user.create", "payload": { - "userId": "@c:host" + "userId": "@c:host", + "password": "__RANDOM__" } }, { diff --git a/corporal/reconciliation/computator/testdata/18-missing-user-with-passthrough-auth-is-created.json b/corporal/reconciliation/computator/testdata/18-missing-user-with-passthrough-auth-is-created.json new file mode 100644 index 0000000..73621e8 --- /dev/null +++ b/corporal/reconciliation/computator/testdata/18-missing-user-with-passthrough-auth-is-created.json @@ -0,0 +1,62 @@ +{ + "currentState": { + "users": [ + ] + }, + + "policy": { + "schemaVersion": 1, + + "flags": { + "allowCustomUserDisplayNames": true, + "allowCustomUserAvatars": true + }, + + "managedRoomIds": [ + "!a:host" + ], + + "managedCommunityIds": [ + "+a:host" + ], + + "users": [ + { + "id": "@a:host", + "authType": "passthrough", + "authCredential": "some-initial-password", + "active": true, + "joinedCommunityIds": ["+a:host"], + "joinedRoomIds": ["!a:host"] + } + ] + }, + + "reconciliationState": { + "actions": [ + { + "type": "user.create", + "payload": { + "userId": "@a:host", + "password": "some-initial-password" + } + }, + + { + "type": "community.join", + "payload": { + "userId": "@a:host", + "communityId": "+a:host" + } + }, + + { + "type": "room.join", + "payload": { + "userId": "@a:host", + "roomId": "!a:host" + } + } + ] + } +} diff --git a/corporal/reconciliation/reconciler/reconciler.go b/corporal/reconciliation/reconciler/reconciler.go index 6f67ef5..bbb47f6 100644 --- a/corporal/reconciliation/reconciler/reconciler.go +++ b/corporal/reconciliation/reconciler/reconciler.go @@ -104,7 +104,12 @@ func (me *Reconciler) reconcileForActionUserCreate(ctx *connector.AccessTokenCon return err } - err = me.connector.EnsureUserAccountExists(userId) + password, err := action.GetStringPayloadDataByKey("password") + if err != nil { + return err + } + + err = me.connector.EnsureUserAccountExists(userId, password) if err != nil { return fmt.Errorf("Failed ensuring %s is created: %s", userId, err) } diff --git a/corporal/userauth/hash.go b/corporal/userauth/hash.go index e230f0a..e280f62 100644 --- a/corporal/userauth/hash.go +++ b/corporal/userauth/hash.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "crypto/sha512" "crypto/subtle" + "devture-matrix-corporal/corporal/policy" "encoding/hex" "hash" ) @@ -41,17 +42,17 @@ func (me *HashAuthenticator) Authenticate(userId, givenPassword, authCredential } func NewMd5Authenticator() Authenticator { - return NewHashAuthenticator(md5.New(), "md5") + return NewHashAuthenticator(md5.New(), policy.UserAuthTypeMd5) } func NewSha1Authenticator() Authenticator { - return NewHashAuthenticator(sha1.New(), "sha1") + return NewHashAuthenticator(sha1.New(), policy.UserAuthTypeSha1) } func NewSha256Authenticator() Authenticator { - return NewHashAuthenticator(sha256.New(), "sha256") + return NewHashAuthenticator(sha256.New(), policy.UserAuthTypeSha256) } func NewSha512Authenticator() Authenticator { - return NewHashAuthenticator(sha512.New(), "sha512") + return NewHashAuthenticator(sha512.New(), policy.UserAuthTypeSha512) } diff --git a/docs/policy.md b/docs/policy.md index 332d8f7..3a3e743 100644 --- a/docs/policy.md +++ b/docs/policy.md @@ -16,6 +16,7 @@ The policy is a JSON document that looks like this: "flags": { "allowCustomUserDisplayNames": false, "allowCustomUserAvatars": false, + "allowCustomPassthroughUserPasswords": false, "forbidRoomCreation": false }, @@ -90,6 +91,8 @@ The following policy flags are supported: - `allowCustomUserAvatars` (`true` or `false`, defaults to `false`) - controls whether users are allowed to set custom avatar images. By default, users are created with the avatar image specified in the policy. Whether they're able to set a custom one by themselves later on is controlled by this flag. +- `allowCustomPassthroughUserPasswords` (`true` or `false`, defaults to `false`) - controls whether users with `authType=passthrough` can set custom passwsords. By default, such users are created with an initial password as defined in `authCredential`. Whether they can change their homeserver password later or not is controlled by this flag. + - `forbidRoomCreation` (`true` or `false`, defaults to `false`) - controls whether users are forbidden from creating rooms. The `forbidRoomCreation` [User policy field](#user-policy-fields) takes precedence over this. This is just a global default in case the user policy does not specify a value. diff --git a/docs/user-authentication.md b/docs/user-authentication.md index e475429..b224851 100644 --- a/docs/user-authentication.md +++ b/docs/user-authentication.md @@ -2,10 +2,11 @@ A [policy](policy.md) contains various users, which `matrix-corporal` manages. -Each user can be authenticated in a different way. -For some users, passwords can be specified in the policy as plain-text. -For other users, passwords can be specified in the policy as a hash (`md5`, `sha1`, etc.). -For others still, passwords can be avoided in the policy and authentication can happen with a REST API call. +Each user can be authenticated in a different way: +- using a passwords specified in the policy as plain-text. See [Plain-text passwords](#plain-text-passwords) +- by using an initial plain-text password specified in the policy, but then delegating password management to the homeserver. See [Passthrough authentication](#passthrough-authentication) +- using a password specified in the policy as a hash (`md5`, `sha1`, etc.). See [Hashed passwords](#hashed-passwords) +- by not specifying a password in the policy, but rather delegating authentication to some REST API. See [External authentication via REST API calls](#external-authentication-via-rest-api-calls) The `authType` field in the user policy (see [user policy fields](policy.md#user-policy-fields)), specifies the authentication method for the given user. The `authCredential` field usually contains the actual password, but may contain some other configuration depending on the authentication type (see below). @@ -16,36 +17,83 @@ If you're curious how `matrix-corporal` makes authentication work behind the sce The simplest (and most insecure) way to specify passwords for users in your policy is to embed the passwords in the policy as plain text. -Here's an example user policy: +Here's an example policy: ```json { - "id": "@john:example.com", - "active": true, - "authType": "plain", - "authCredential": "PaSSw0rD", - "displayName": "John", - "avatarUri": "https://example.com/john.jpg", - "joinedCommunityIds": ["+a:example.com"], - "joinedRoomIds": ["!roomA:example.com", "!roomB:example.com"] + "users": [ + { + "id": "@john:example.com", + "active": true, + "authType": "plain", + "authCredential": "PaSSw0rD", + "displayName": "John", + "avatarUri": "https://example.com/john.jpg", + "joinedCommunityIds": ["+a:example.com"], + "joinedRoomIds": ["!roomA:example.com", "!roomB:example.com"] + } + ] +} +``` + +Users are created on the homeserver with a long-random password. Still, authentication is intercepted by matrix-corporal and password-matching is performed against the **current** password found in `authCredential`. + + +## Passthrough authentication + +A variation of [Plain-text passwords](#plain-text-passwords) is the `passthrough` authentication type. + +It's similar to plain-text authentication, but: + +- actually creates users on the homeserver with the given plain-text password (as opposed to a random long password) +- subsequent changes to the `authCredential` value in the policy do not update the homeserver password (that is, `authCredential` is just an initial password) +- authentication is not handled in matrix-corporal (as with all other auth types), but is instead forwarded to the homeserver and happens against the password stored there +- users **may** be allowed to change their homeserver password, depending on the `allowCustomPassthroughUserPasswords` flag in the **main** policy (defaults to `false`). See [Policy Flags](policy.md#flags) + +Here's an example policy: + +```json +{ + "flags": { + "allowCustomPassthroughUserPasswords": true + }, + + "users": [ + { + "id": "@john:example.com", + "active": true, + "authType": "passthrough", + "authCredential": "some-initial-password", + "displayName": "John", + "avatarUri": "https://example.com/john.jpg", + "joinedCommunityIds": ["+a:example.com"], + "joinedRoomIds": ["!roomA:example.com", "!roomB:example.com"] + } + ] } ``` ## Hashed passwords -For additional security (or in case you only have a hashed password for your users), you can specify passwords as hashed in your user policy. Example: +For additional security (or in case you only have a hashed password for your users), you can specify passwords as hashed in your user policy. + +Here's an example policy: ```json { - "id": "@peter:example.com", - "active": true, - "authType": "sha1", - "authCredential": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", - "displayName": "Just Peter", - "avatarUri": "", - "joinedCommunityIds": ["+b:example.com"], - "joinedRoomIds": ["!roomB:example.com"] + "users": [ + { + "id": "@peter:example.com", + "active": true, + "authType": "sha1", + "authCredential": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", + "displayName": "Just Peter", + "avatarUri": "", + "joinedCommunityIds": ["+b:example.com"], + "joinedRoomIds": ["!roomB:example.com"] + } + ] } ``` @@ -60,18 +108,22 @@ If you'd rather not have passwords right inside the policy document, you can pro This way, `matrix-corporal` relies on an external HTTP service to do the actual authentication. -Example user policy: +Here's an example policy: ```json { - "id": "@george:example.com", - "active": true, - "authType": "rest", - "authCredential": "https://intranet.example.com/_matrix-internal/identity/v1/check_credentials", - "displayName": "Georgey", - "avatarUri": "", - "joinedCommunityIds": ["+a:example.com", "+b:example.com"], - "joinedRoomIds": ["!roomA:example.com", "!roomB:example.com"] + "users": [ + { + "id": "@george:example.com", + "active": true, + "authType": "rest", + "authCredential": "https://intranet.example.com/_matrix-internal/identity/v1/check_credentials", + "displayName": "Georgey", + "avatarUri": "", + "joinedCommunityIds": ["+a:example.com", "+b:example.com"], + "joinedRoomIds": ["!roomA:example.com", "!roomB:example.com"] + } + ] } ``` @@ -127,7 +179,9 @@ Authentication requests with a login flow of `m.login.token` (used by CAS/SAML S Authentication requests for users not managed by `matrix-corporal` (users that do not have a corresponding user policy in the [policy](policy.md)) are directly forwarded to the upstream server -- these users are not managed by `matrix-corporal`, so they are left alone. -If a user is managed by `matrix-corporal`, authentication proceeds depending on the [user authentication](user-authentication.md) type (`authType` user policy field) for the particular user trying to log in. +Requests for users having `authType=passthrough` are forwarded to the upstream server unchanged. + +For requests for users having another auth type (different than `passthrough`), authentication proceeds depending on the [user authentication](user-authentication.md) type (`authType` user policy field) for the particular user trying to log in. If the request ends up being **not authenticated**, `matrix-corporal` outright rejects it and it never reaches the upstream server. diff --git a/policy.json.dist b/policy.json.dist index 720cc9c..8c29eda 100644 --- a/policy.json.dist +++ b/policy.json.dist @@ -3,6 +3,7 @@ "flags": { "allowCustomUserDisplayNames": false, "allowCustomUserAvatars": false, + "allowCustomPassthroughUserPasswords": false, "forbidRoomCreation": false },