mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-04-15 05:44:11 +00:00
[feature] add TOTP two-factor authentication (2FA) (#3960)
* [feature] add TOTP two-factor authentication (2FA) * use byteutil.S2B to avoid allocations when comparing + generating password hashes * don't bother with string conversion for consts * use io.ReadFull * use MustGenerateSecret for backup codes * rename util functions
This commit is contained in:
parent
6f24205a26
commit
365b575341
78 changed files with 5593 additions and 825 deletions
|
@ -231,6 +231,7 @@ Simply download the binary + assets (or Docker container), tweak your configurat
|
|||
- [Import, export](https://docs.gotosocial.org/en/latest/admin/settings/#importexport), and [subscribe](https://docs.gotosocial.org/en/latest/admin/domain_permission_subscriptions) to community-created domain allow and domain block lists.
|
||||
- HTTP signature authentication: GoToSocial requires [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12) when sending and receiving messages, to ensure that your messages can't be tampered with and your identity can't be forged.
|
||||
- Built-in, automatic support for secure HTTPS with [Let's Encrypt](https://letsencrypt.org/).
|
||||
- Support for two-factor authentication via time-based one-time passwords (Google authenticator, LastPass authenticator, etc).
|
||||
|
||||
### Various federation modes
|
||||
|
||||
|
@ -426,6 +427,7 @@ The following open source libraries, frameworks, and tools are used by GoToSocia
|
|||
- [mvdan.cc/xurls](https://github.com/mvdan/xurls); URL parsing regular expressions. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html).
|
||||
- [oklog/ulid](https://github.com/oklog/ulid); sequential, database-friendly ID generation. [Apache-2.0 License](https://spdx.org/licenses/Apache-2.0.html).
|
||||
- [open-telemetry/opentelemetry-go](https://github.com/open-telemetry/opentelemetry-go); OpenTelemetry API + SDK. [Apache-2.0 License](https://spdx.org/licenses/Apache-2.0.html).
|
||||
- [pquerna/otp](https://github.com/pquerna/otp); One Time Password utilities. [Apache-2.0 License](https://spdx.org/licenses/Apache-2.0.html).
|
||||
- spf13:
|
||||
- [spf13/cobra](https://github.com/spf13/cobra); command-line tooling. [Apache-2.0 License](https://spdx.org/licenses/Apache-2.0.html).
|
||||
- [spf13/viper](https://github.com/spf13/viper); configuration management. [Apache-2.0 License](https://spdx.org/licenses/Apache-2.0.html).
|
||||
|
|
|
@ -73,10 +73,10 @@ These are provided in no specific order.
|
|||
- [x] **Non-replyable posts** -- design a non-replyable post path for GoToSocial based on https://github.com/mastodon/mastodon/issues/14762#issuecomment-1196889788; allow users to create non-replyable posts.
|
||||
- [x] **Block + allow list subscriptions** -- allow instance admins to subscribe their instance to domain block/allow lists.
|
||||
- [x] **Direct conversation view** -- allow users to easily page through all direct-message conversations they're a part of.
|
||||
- [ ] **Oauth token management** -- create / view / invalidate OAuth tokens via the settings panel.
|
||||
- [ ] **Status EDIT support** -- edit statuses that you've created, without having to delete + redraft. Federate edits out properly.
|
||||
- [x] **Oauth token management** -- create / view / invalidate OAuth tokens via the settings panel.
|
||||
- [x] **Status EDIT support** -- edit statuses that you've created, without having to delete + redraft. Federate edits out properly.
|
||||
- [ ] **Fediverse relay support** -- publish posts to relays, pull posts from relays.
|
||||
- [ ] **Two factor authentication (2fa)** -- allow users to enable 2FA for their account via the settings panel, enforce 2FA on login.
|
||||
- [x] **Two factor authentication (2fa)** -- allow users to enable 2FA for their account via the settings panel, enforce 2FA on login.
|
||||
- [ ] **Moderation: Append content warning / mark-as-sensitive all content from an instance/account**.
|
||||
|
||||
More tbd!
|
||||
|
|
|
@ -515,16 +515,16 @@ var Start action.GTSAction = func(ctx context.Context) error {
|
|||
}
|
||||
|
||||
var (
|
||||
authModule = api.NewAuth(dbService, process, idp, routerSession, sessionName) // auth/oauth paths
|
||||
clientModule = api.NewClient(state, process) // api client endpoints
|
||||
metricsModule = api.NewMetrics() // Metrics endpoints
|
||||
healthModule = api.NewHealth(dbService.Ready) // Health check endpoints
|
||||
fileserverModule = api.NewFileserver(process) // fileserver endpoints
|
||||
robotsModule = api.NewRobots() // robots.txt endpoint
|
||||
wellKnownModule = api.NewWellKnown(process) // .well-known endpoints
|
||||
nodeInfoModule = api.NewNodeInfo(process) // nodeinfo endpoint
|
||||
activityPubModule = api.NewActivityPub(dbService, process) // ActivityPub endpoints
|
||||
webModule = web.New(dbService, process) // web pages + user profiles + settings panels etc
|
||||
authModule = api.NewAuth(state, process, idp, routerSession, sessionName) // auth/oauth paths
|
||||
clientModule = api.NewClient(state, process) // api client endpoints
|
||||
metricsModule = api.NewMetrics() // Metrics endpoints
|
||||
healthModule = api.NewHealth(dbService.Ready) // Health check endpoints
|
||||
fileserverModule = api.NewFileserver(process) // fileserver endpoints
|
||||
robotsModule = api.NewRobots() // robots.txt endpoint
|
||||
wellKnownModule = api.NewWellKnown(process) // .well-known endpoints
|
||||
nodeInfoModule = api.NewNodeInfo(process) // nodeinfo endpoint
|
||||
activityPubModule = api.NewActivityPub(dbService, process) // ActivityPub endpoints
|
||||
webModule = web.New(dbService, process) // web pages + user profiles + settings panels etc
|
||||
)
|
||||
|
||||
// Create per-route / per-grouping middlewares.
|
||||
|
|
|
@ -283,16 +283,16 @@ var Start action.GTSAction = func(ctx context.Context) error {
|
|||
}
|
||||
|
||||
var (
|
||||
authModule = api.NewAuth(state.DB, processor, idp, routerSession, sessionName) // auth/oauth paths
|
||||
clientModule = api.NewClient(state, processor) // api client endpoints
|
||||
metricsModule = api.NewMetrics() // Metrics endpoints
|
||||
healthModule = api.NewHealth(state.DB.Ready) // Health check endpoints
|
||||
fileserverModule = api.NewFileserver(processor) // fileserver endpoints
|
||||
robotsModule = api.NewRobots() // robots.txt endpoint
|
||||
wellKnownModule = api.NewWellKnown(processor) // .well-known endpoints
|
||||
nodeInfoModule = api.NewNodeInfo(processor) // nodeinfo endpoint
|
||||
activityPubModule = api.NewActivityPub(state.DB, processor) // ActivityPub endpoints
|
||||
webModule = web.New(state.DB, processor) // web pages + user profiles + settings panels etc
|
||||
authModule = api.NewAuth(state, processor, idp, routerSession, sessionName) // auth/oauth paths
|
||||
clientModule = api.NewClient(state, processor) // api client endpoints
|
||||
metricsModule = api.NewMetrics() // Metrics endpoints
|
||||
healthModule = api.NewHealth(state.DB.Ready) // Health check endpoints
|
||||
fileserverModule = api.NewFileserver(processor) // fileserver endpoints
|
||||
robotsModule = api.NewRobots() // robots.txt endpoint
|
||||
wellKnownModule = api.NewWellKnown(processor) // .well-known endpoints
|
||||
nodeInfoModule = api.NewNodeInfo(processor) // nodeinfo endpoint
|
||||
activityPubModule = api.NewActivityPub(state.DB, processor) // ActivityPub endpoints
|
||||
webModule = web.New(state.DB, processor) // web pages + user profiles + settings panels etc
|
||||
)
|
||||
|
||||
// these should be routed in order
|
||||
|
|
|
@ -3510,6 +3510,11 @@ definitions:
|
|||
example: "2021-07-30T09:20:25+00:00"
|
||||
type: string
|
||||
x-go-name: ResetPasswordSentAt
|
||||
two_factor_enabled_at:
|
||||
description: Time at which 2fa was enabled for this user. (ISO 8601 Datetime)
|
||||
example: "2021-07-30T09:20:25+00:00"
|
||||
type: string
|
||||
x-go-name: TwoFactorEnabledAt
|
||||
unconfirmed_email:
|
||||
description: Unconfirmed email address of this user, if set.
|
||||
example: someone.else@somewhere.else.example.org
|
||||
|
@ -12141,6 +12146,146 @@ paths:
|
|||
summary: Get your own user model.
|
||||
tags:
|
||||
- user
|
||||
/api/v1/user/2fa/disable:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
- application/x-www-form-urlencoded
|
||||
description: |-
|
||||
If 2fa is already disabled for this user, code 409 Conflict will be returned.
|
||||
|
||||
If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
operationId: TwoFactorDisablePost
|
||||
parameters:
|
||||
- description: User's current password, for verification.
|
||||
in: formData
|
||||
name: password
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: QR code
|
||||
"401":
|
||||
description: unauthorized
|
||||
"403":
|
||||
description: forbidden
|
||||
"406":
|
||||
description: not acceptable
|
||||
"409":
|
||||
description: conflict
|
||||
"422":
|
||||
description: unprocessable entity
|
||||
"500":
|
||||
description: internal error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- write:accounts
|
||||
summary: Disable 2fa for the authorized user. User's current password must be provided for verification purposes.
|
||||
tags:
|
||||
- user
|
||||
/api/v1/user/2fa/enable:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
- application/x-www-form-urlencoded
|
||||
description: |-
|
||||
If 2fa is already enabled for this user, code 409 Conflict will be returned.
|
||||
|
||||
If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
operationId: TwoFactorEnablePost
|
||||
parameters:
|
||||
- description: |-
|
||||
2fa code from the user's authenticator app.
|
||||
Sample: 123456
|
||||
in: formData
|
||||
name: code
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: QR code
|
||||
"401":
|
||||
description: unauthorized
|
||||
"403":
|
||||
description: forbidden
|
||||
"406":
|
||||
description: not acceptable
|
||||
"409":
|
||||
description: conflict
|
||||
"422":
|
||||
description: unprocessable entity
|
||||
"500":
|
||||
description: internal error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- write:accounts
|
||||
summary: Enable 2fa for the authorized user, using the provided code from an authenticator app, and return an array of one-time recovery codes to allow bypassing 2fa.
|
||||
tags:
|
||||
- user
|
||||
/api/v1/user/2fa/qr.png:
|
||||
get:
|
||||
description: |-
|
||||
For the plaintext version of the QR code URI, call /api/v1/user/2fa/qruri instead.
|
||||
|
||||
If 2fa is already enabled for this user, the QR code (with its secret) will not be shared again. Instead, code 409 Conflict will be returned. To get a fresh secret, first disable 2fa using POST /api/v1/user/2fa/disable, and then call this endpoint again.
|
||||
|
||||
If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
operationId: TwoFactorQRCodePngGet
|
||||
produces:
|
||||
- image/png
|
||||
responses:
|
||||
"200":
|
||||
description: QR code png
|
||||
"401":
|
||||
description: unauthorized
|
||||
"403":
|
||||
description: forbidden
|
||||
"406":
|
||||
description: not acceptable
|
||||
"409":
|
||||
description: conflict
|
||||
"422":
|
||||
description: unprocessable entity
|
||||
"500":
|
||||
description: internal error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- read:accounts
|
||||
summary: Return a QR code png to allow the authorized user to enable 2fa for their login.
|
||||
tags:
|
||||
- user
|
||||
/api/v1/user/2fa/qruri:
|
||||
get:
|
||||
description: |-
|
||||
For a png of the QR code, call /api/v1/user/2fa/qr.png instead.
|
||||
|
||||
If 2fa is already enabled for this user, the QR code URI (with its secret) will not be shared again. Instead, code 409 Conflict will be returned. To get a fresh secret, first disable 2fa using POST /api/v1/user/2fa/disable, and then call this endpoint again.
|
||||
|
||||
If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
operationId: TwoFactorQRCodeURIGet
|
||||
produces:
|
||||
- text/plain
|
||||
responses:
|
||||
"200":
|
||||
description: QR code uri
|
||||
"401":
|
||||
description: unauthorized
|
||||
"403":
|
||||
description: forbidden
|
||||
"406":
|
||||
description: not acceptable
|
||||
"409":
|
||||
description: conflict
|
||||
"422":
|
||||
description: unprocessable entity
|
||||
"500":
|
||||
description: internal error
|
||||
security:
|
||||
- OAuth2 Bearer:
|
||||
- read:accounts
|
||||
summary: Return a QR code uri to allow the authorized user to enable 2fa for their login.
|
||||
tags:
|
||||
- user
|
||||
/api/v1/user/email_change:
|
||||
post:
|
||||
consumes:
|
||||
|
|
|
@ -225,7 +225,9 @@ If you want to reset all your policies to the initial defaults, you can click on
|
|||
|
||||
As more ActivityPub servers roll out support for interaction policies, this issue will hopefully diminish, but in the meantime GoToSocial can offer only a "best effort" attempt to restrict interactions with your posts according to the policies you have set.
|
||||
|
||||
## Email & Password
|
||||
## Account
|
||||
|
||||
In the "Account" section, you can set your email and password, and set up two-factor authentication for your account.
|
||||
|
||||
### Email Change
|
||||
|
||||
|
@ -245,6 +247,24 @@ You can use the Password Change section of the panel to set a new password for y
|
|||
|
||||
For more information on the way GoToSocial manages passwords, please see the [Password management document](./password_management.md).
|
||||
|
||||
### Two-Factor Authentication
|
||||
|
||||
You can use this section of the panel to enable two-factor authentication (2FA) for your account.
|
||||
|
||||
With 2FA enabled, you will have to provide a code from your configured authenticator app (Google authenticator, LastPass authenticator, etc) when you want to log in, in addition to your password.
|
||||
|
||||
To enable 2FA, install an authenticator app on your mobile device, and use it to scan the QR code. You can also copy the 2FA secret manually and paste it into your authenticator. Once you've done that, enter a code from your authenticator to verify that the authenticator and the server are synced up correctly.
|
||||
|
||||
On success, you will be shown a list of eight 2FA backup/recovery codes. Save these codes in a safe place (eg., in a password manager). If you lose access to your authenticator app, for example if you lose your device, then you can use one of these codes instead of a 2FA code when logging in. Once you have used a code, you will not be able to use it again. Should you use up all eight recovery codes, you should disable and reenable 2FA to generate new ones.
|
||||
|
||||
To disable two-factor authentication, enter your current password in the form, and click the "Disable 2FA" button.
|
||||
|
||||
!!! tip
|
||||
Two-factor authentication is recommended, as it makes it more difficult for baddies to log in to your account by guessing your password, since they would also need access to your authenticator device. For more information on different types of 2FA, see [A Guide to Common Types of Two-Factor Authentication on the Web](https://www.eff.org/deeplinks/2017/09/guide-common-types-two-factor-authentication-web).
|
||||
|
||||
!!! info
|
||||
If your instance is using OIDC as its authorization/identity provider, you will not be able to enable 2FA in the settings panel, and you should contact your OIDC provider instead.
|
||||
|
||||
## Migration
|
||||
|
||||
In the migration section you can manage settings related to aliasing and/or migrating your account to or from another account.
|
||||
|
|
2
go.mod
2
go.mod
|
@ -56,6 +56,7 @@ require (
|
|||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/ncruces/go-sqlite3 v0.25.0
|
||||
github.com/oklog/ulid v1.3.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/spf13/cobra v1.9.1
|
||||
|
@ -106,6 +107,7 @@ require (
|
|||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/sonic v1.12.7 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.2 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
|
|
4
go.sum
generated
4
go.sum
generated
|
@ -77,6 +77,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
|||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
|
||||
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
|
||||
github.com/bytedance/sonic v1.12.7 h1:CQU8pxOy9HToxhndH0Kx/S1qU/CuS9GnKYrGioDcU1Q=
|
||||
|
@ -342,6 +344,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
|
|
|
@ -20,12 +20,12 @@ package api
|
|||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/auth"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/middleware"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oidc"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
)
|
||||
|
||||
type Auth struct {
|
||||
|
@ -55,13 +55,19 @@ func (a *Auth) Route(r *router.Router, m ...gin.HandlerFunc) {
|
|||
oauthGroup.Use(ccMiddleware, sessionMiddleware)
|
||||
|
||||
a.auth.RouteAuth(authGroup.Handle)
|
||||
a.auth.RouteOauth(oauthGroup.Handle)
|
||||
a.auth.RouteOAuth(oauthGroup.Handle)
|
||||
}
|
||||
|
||||
func NewAuth(db db.DB, p *processing.Processor, idp oidc.IDP, routerSession *gtsmodel.RouterSession, sessionName string) *Auth {
|
||||
func NewAuth(
|
||||
state *state.State,
|
||||
p *processing.Processor,
|
||||
idp oidc.IDP,
|
||||
routerSession *gtsmodel.RouterSession,
|
||||
sessionName string,
|
||||
) *Auth {
|
||||
return &Auth{
|
||||
routerSession: routerSession,
|
||||
sessionName: sessionName,
|
||||
auth: auth.New(db, p, idp),
|
||||
auth: auth.New(state, p, idp),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,11 +20,10 @@ package auth
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oidc"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -32,61 +31,58 @@ const (
|
|||
paths prefixed with 'auth'
|
||||
*/
|
||||
|
||||
// AuthSignInPath is the API path for users to sign in through
|
||||
AuthSignInPath = "/sign_in"
|
||||
// AuthCheckYourEmailPath users land here after registering a new account, instructs them to confirm their email
|
||||
AuthCheckYourEmailPath = "/check_your_email"
|
||||
// AuthWaitForApprovalPath users land here after confirming their email
|
||||
// but before an admin approves their account (if such is required)
|
||||
AuthSignInPath = "/sign_in"
|
||||
Auth2FAPath = "/2fa"
|
||||
AuthCheckYourEmailPath = "/check_your_email"
|
||||
AuthWaitForApprovalPath = "/wait_for_approval"
|
||||
// AuthAccountDisabledPath users land here when their account is suspended by an admin
|
||||
AuthAccountDisabledPath = "/account_disabled"
|
||||
// AuthCallbackPath is the API path for receiving callback tokens from external OIDC providers
|
||||
AuthCallbackPath = "/callback"
|
||||
AuthCallbackPath = "/callback"
|
||||
|
||||
/*
|
||||
paths prefixed with 'oauth'
|
||||
*/
|
||||
|
||||
// OauthTokenPath is the API path to use for granting token requests to users with valid credentials
|
||||
OauthTokenPath = "/token" // #nosec G101 else we get a hardcoded credentials warning
|
||||
// OauthAuthorizePath is the API path for authorization requests (eg., authorize this app to act on my behalf as a user)
|
||||
OauthAuthorizePath = "/authorize"
|
||||
// OauthFinalizePath is the API path for completing user registration with additional user details
|
||||
OauthFinalizePath = "/finalize"
|
||||
// OauthOobTokenPath is the path for serving an html representation of an oob token page.
|
||||
OauthOobTokenPath = "/oob" // #nosec G101 else we get a hardcoded credentials warning
|
||||
OauthFinalizePath = "/finalize"
|
||||
OauthOOBTokenPath = "/oob" // #nosec G101 else we get a hardcoded credentials warning
|
||||
OauthTokenPath = "/token" // #nosec G101 else we get a hardcoded credentials warning
|
||||
|
||||
/*
|
||||
params / session keys
|
||||
*/
|
||||
|
||||
callbackStateParam = "state"
|
||||
callbackCodeParam = "code"
|
||||
sessionUserID = "userid"
|
||||
sessionClientID = "client_id"
|
||||
sessionRedirectURI = "redirect_uri"
|
||||
sessionForceLogin = "force_login"
|
||||
sessionResponseType = "response_type"
|
||||
sessionScope = "scope"
|
||||
sessionInternalState = "internal_state"
|
||||
sessionClientState = "client_state"
|
||||
sessionClaims = "claims"
|
||||
sessionAppID = "app_id"
|
||||
callbackStateParam = "state"
|
||||
callbackCodeParam = "code"
|
||||
sessionUserID = "userid"
|
||||
sessionUserIDAwaiting2FA = "userid_awaiting_2fa"
|
||||
sessionClientID = "client_id"
|
||||
sessionRedirectURI = "redirect_uri"
|
||||
sessionForceLogin = "force_login"
|
||||
sessionResponseType = "response_type"
|
||||
sessionScope = "scope"
|
||||
sessionInternalState = "internal_state"
|
||||
sessionClientState = "client_state"
|
||||
sessionClaims = "claims"
|
||||
sessionAppID = "app_id"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
db db.DB
|
||||
state *state.State
|
||||
processor *processing.Processor
|
||||
idp oidc.IDP
|
||||
}
|
||||
|
||||
// New returns an Auth module which provides both 'oauth' and 'auth' endpoints.
|
||||
// New returns an Auth module which provides
|
||||
// both 'oauth' and 'auth' endpoints.
|
||||
//
|
||||
// It is safe to pass a nil idp if oidc is disabled.
|
||||
func New(db db.DB, processor *processing.Processor, idp oidc.IDP) *Module {
|
||||
func New(
|
||||
state *state.State,
|
||||
processor *processing.Processor,
|
||||
idp oidc.IDP,
|
||||
) *Module {
|
||||
return &Module{
|
||||
db: db,
|
||||
state: state,
|
||||
processor: processor,
|
||||
idp: idp,
|
||||
}
|
||||
|
@ -96,21 +92,16 @@ func New(db db.DB, processor *processing.Processor, idp oidc.IDP) *Module {
|
|||
func (m *Module) RouteAuth(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
attachHandler(http.MethodGet, AuthSignInPath, m.SignInGETHandler)
|
||||
attachHandler(http.MethodPost, AuthSignInPath, m.SignInPOSTHandler)
|
||||
attachHandler(http.MethodGet, Auth2FAPath, m.TwoFactorCodeGETHandler)
|
||||
attachHandler(http.MethodPost, Auth2FAPath, m.TwoFactorCodePOSTHandler)
|
||||
attachHandler(http.MethodGet, AuthCallbackPath, m.CallbackGETHandler)
|
||||
}
|
||||
|
||||
// RouteOauth routes all paths that should have an 'oauth' prefix
|
||||
func (m *Module) RouteOauth(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
// RouteOAuth routes all paths that should have an 'oauth' prefix
|
||||
func (m *Module) RouteOAuth(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||
attachHandler(http.MethodPost, OauthTokenPath, m.TokenPOSTHandler)
|
||||
attachHandler(http.MethodGet, OauthAuthorizePath, m.AuthorizeGETHandler)
|
||||
attachHandler(http.MethodPost, OauthAuthorizePath, m.AuthorizePOSTHandler)
|
||||
attachHandler(http.MethodPost, OauthFinalizePath, m.FinalizePOSTHandler)
|
||||
attachHandler(http.MethodGet, OauthOobTokenPath, m.OobHandler)
|
||||
}
|
||||
|
||||
func (m *Module) clearSession(s sessions.Session) {
|
||||
s.Clear()
|
||||
if err := s.Save(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
attachHandler(http.MethodGet, OauthOOBTokenPath, m.OOBTokenGETHandler)
|
||||
}
|
||||
|
|
|
@ -96,7 +96,7 @@ func (suite *AuthStandardTestSuite) SetupTest() {
|
|||
testrig.NewNoopWebPushSender(),
|
||||
suite.mediaManager,
|
||||
)
|
||||
suite.authModule = auth.New(suite.db, suite.processor, suite.idp)
|
||||
suite.authModule = auth.New(&suite.state, suite.processor, suite.idp)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, suite.testAccounts)
|
||||
testrig.StartNoopWorkers(&suite.state)
|
||||
|
|
|
@ -18,8 +18,6 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
|
@ -28,280 +26,227 @@ import (
|
|||
"github.com/google/uuid"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
// AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize
|
||||
// The idea here is to present an oauth authorize page to the user, with a button
|
||||
// that they have to click to accept.
|
||||
// AuthorizeGETHandler should be served as
|
||||
// GET at https://example.org/oauth/authorize.
|
||||
//
|
||||
// The idea here is to present an authorization
|
||||
// page to the user, informing them of the scopes
|
||||
// the application is requesting, with a button
|
||||
// that they have to click to give it permission.
|
||||
func (m *Module) AuthorizeGETHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.HTMLAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// UserID will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow
|
||||
// If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page.
|
||||
userID, ok := s.Get(sessionUserID).(string)
|
||||
if !ok || userID == "" {
|
||||
form := &apimodel.OAuthAuthorize{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
s := sessions.Default(c)
|
||||
|
||||
if errWithCode := saveAuthFormToSession(s, form); errWithCode != nil {
|
||||
m.clearSession(s)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusSeeOther, "/auth"+AuthSignInPath)
|
||||
// UserID will be set in the session by
|
||||
// AuthorizePOSTHandler if the caller has
|
||||
// already gone through the auth flow.
|
||||
//
|
||||
// If it's not set, then we don't yet know
|
||||
// yet who the user is, so send them to the
|
||||
// sign in page first.
|
||||
if userID, ok := s.Get(sessionUserID).(string); !ok || userID == "" {
|
||||
m.redirectAuthFormToSignIn(c)
|
||||
return
|
||||
}
|
||||
|
||||
// use session information to validate app, user, and account for this request
|
||||
clientID, ok := s.Get(sessionClientID).(string)
|
||||
if !ok || clientID == "" {
|
||||
m.clearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionClientID)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
user := m.mustUserFromSession(c, s)
|
||||
if user == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
app, err := m.db.GetApplicationByClientID(c.Request.Context(), clientID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.db.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("user with id %s could not be retrieved", userID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if ensureUserIsAuthorizedOrRedirect(c, user, acct) {
|
||||
return
|
||||
}
|
||||
|
||||
// Finally we should also get the redirect and scope of this particular request, as stored in the session.
|
||||
redirect, ok := s.Get(sessionRedirectURI).(string)
|
||||
if !ok || redirect == "" {
|
||||
m.clearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionRedirectURI)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
scope, ok := s.Get(sessionScope).(string)
|
||||
if !ok || scope == "" {
|
||||
m.clearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionScope)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
// If the user is unconfirmed, waiting approval,
|
||||
// or suspended, redirect to an appropriate help page.
|
||||
if !m.validateUser(c, user) {
|
||||
// Already
|
||||
// redirected.
|
||||
return
|
||||
}
|
||||
|
||||
// Everything looks OK.
|
||||
// Start preparing to render the html template.
|
||||
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := m.mustStringFromSession(c, s, sessionRedirectURI)
|
||||
if redirectURI == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
scope := m.mustStringFromSession(c, s, sessionScope)
|
||||
if scope == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
app := m.mustAppFromSession(c, s)
|
||||
if app == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
// The authorize template will display a form
|
||||
// to the user where they can see some info
|
||||
// about the app that's trying to authorize,
|
||||
// and the scope of the request. They can then
|
||||
// approve it if it looks OK to them, which
|
||||
// will POST to the AuthorizePOSTHandler.
|
||||
page := apiutil.WebPage{
|
||||
apiutil.TemplateWebPage(c, apiutil.WebPage{
|
||||
Template: "authorize.tmpl",
|
||||
Instance: instance,
|
||||
Extra: map[string]any{
|
||||
"appname": app.Name,
|
||||
"appwebsite": app.Website,
|
||||
"redirect": redirect,
|
||||
"redirect": redirectURI,
|
||||
"scope": scope,
|
||||
"user": acct.Username,
|
||||
"user": user.Account.Username,
|
||||
},
|
||||
}
|
||||
|
||||
apiutil.TemplateWebPage(c, page)
|
||||
})
|
||||
}
|
||||
|
||||
// AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize
|
||||
// At this point we assume that the user has A) logged in and B) accepted that the app should act for them,
|
||||
// so we should proceed with the authentication flow and generate an oauth token for them if we can.
|
||||
// AuthorizePOSTHandler should be served as
|
||||
// POST at https://example.org/oauth/authorize.
|
||||
//
|
||||
// At this point we assume that the user has signed
|
||||
// in and permitted the app to act on their behalf.
|
||||
// We should proceed with the authentication flow
|
||||
// and generate an oauth code at the redirect URI.
|
||||
func (m *Module) AuthorizePOSTHandler(c *gin.Context) {
|
||||
|
||||
// We need to use the session cookie to
|
||||
// recreate the original form submitted
|
||||
// to the authorizeGEThandler so that it
|
||||
// can be validated by the oauth2 library.
|
||||
s := sessions.Default(c)
|
||||
|
||||
// We need to retrieve the original form submitted to the authorizeGEThandler, and
|
||||
// recreate it on the request so that it can be used further by the oauth2 library.
|
||||
errs := []string{}
|
||||
responseType := m.mustStringFromSession(c, s, sessionResponseType)
|
||||
if responseType == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
clientID := m.mustStringFromSession(c, s, sessionClientID)
|
||||
if clientID == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := m.mustStringFromSession(c, s, sessionRedirectURI)
|
||||
if redirectURI == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
scope := m.mustStringFromSession(c, s, sessionScope)
|
||||
if scope == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
user := m.mustUserFromSession(c, s)
|
||||
if user == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
// Force login is optional with default of "false".
|
||||
forceLogin, ok := s.Get(sessionForceLogin).(string)
|
||||
if !ok {
|
||||
if !ok || forceLogin == "" {
|
||||
forceLogin = "false"
|
||||
}
|
||||
|
||||
responseType, ok := s.Get(sessionResponseType).(string)
|
||||
if !ok || responseType == "" {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionResponseType))
|
||||
}
|
||||
|
||||
clientID, ok := s.Get(sessionClientID).(string)
|
||||
if !ok || clientID == "" {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionClientID))
|
||||
}
|
||||
|
||||
redirectURI, ok := s.Get(sessionRedirectURI).(string)
|
||||
if !ok || redirectURI == "" {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionRedirectURI))
|
||||
}
|
||||
|
||||
scope, ok := s.Get(sessionScope).(string)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope))
|
||||
}
|
||||
|
||||
// Client state is optional with default of "".
|
||||
var clientState string
|
||||
if s, ok := s.Get(sessionClientState).(string); ok {
|
||||
clientState = s
|
||||
if cs, ok := s.Get(sessionClientState).(string); ok {
|
||||
clientState = cs
|
||||
}
|
||||
|
||||
userID, ok := s.Get(sessionUserID).(string)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID))
|
||||
}
|
||||
|
||||
if len(errs) != 0 {
|
||||
errs = append(errs, oauth.HelpfulAdvice)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during AuthorizePOSTHandler"), errs...), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.db.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("user with id %s could not be retrieved", userID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if ensureUserIsAuthorizedOrRedirect(c, user, acct) {
|
||||
// If the user is unconfirmed, waiting approval,
|
||||
// or suspended, redirect to an appropriate help page.
|
||||
if !m.validateUser(c, user) {
|
||||
// Already
|
||||
// redirected.
|
||||
return
|
||||
}
|
||||
|
||||
// If we're redirecting to our OOB token handler,
|
||||
// we need to keep the session around so the OOB
|
||||
// handler can extract values from it. Otherwise,
|
||||
// we're going to be redirecting somewhere else
|
||||
// so we can safely clear the session now.
|
||||
if redirectURI != oauth.OOBURI {
|
||||
// we're done with the session now, so just clear it out
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
}
|
||||
|
||||
// we have to set the values on the request form
|
||||
// so that they're picked up by the oauth server
|
||||
// Set values on the request form so that
|
||||
// they're picked up by the oauth server.
|
||||
c.Request.Form = url.Values{
|
||||
sessionForceLogin: {forceLogin},
|
||||
sessionResponseType: {responseType},
|
||||
sessionClientID: {clientID},
|
||||
sessionRedirectURI: {redirectURI},
|
||||
sessionScope: {scope},
|
||||
sessionUserID: {userID},
|
||||
sessionUserID: {user.ID},
|
||||
sessionForceLogin: {forceLogin},
|
||||
}
|
||||
|
||||
if clientState != "" {
|
||||
// If client state was submitted,
|
||||
// set it on the form so it can be
|
||||
// fed back to the client via a query
|
||||
// param at the eventual redirect URL.
|
||||
c.Request.Form.Set("state", clientState)
|
||||
}
|
||||
|
||||
if errWithCode := m.processor.OAuthHandleAuthorizeRequest(c.Writer, c.Request); errWithCode != nil {
|
||||
// If OAuthHandleAuthorizeRequest is successful,
|
||||
// it'll handle any further redirects for us,
|
||||
// but we do still need to handle any errors.
|
||||
errWithCode := m.processor.OAuthHandleAuthorizeRequest(c.Writer, c.Request)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
}
|
||||
}
|
||||
|
||||
// saveAuthFormToSession checks the given OAuthAuthorize form,
|
||||
// and stores the values in the form into the session.
|
||||
func saveAuthFormToSession(s sessions.Session, form *apimodel.OAuthAuthorize) gtserror.WithCode {
|
||||
if form == nil {
|
||||
err := errors.New("OAuthAuthorize form was nil")
|
||||
return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice)
|
||||
// redirectAuthFormToSignIn binds an OAuthAuthorize form,
|
||||
// stores the values in the form into the session, and
|
||||
// redirects the user to the sign in page.
|
||||
func (m *Module) redirectAuthFormToSignIn(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
form := &apimodel.OAuthAuthorize{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSessionWithBadRequest(c, s, err, err.Error(), oauth.HelpfulAdvice)
|
||||
return
|
||||
}
|
||||
|
||||
if form.ResponseType == "" {
|
||||
err := errors.New("field response_type was not set on OAuthAuthorize form")
|
||||
return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice)
|
||||
}
|
||||
|
||||
if form.ClientID == "" {
|
||||
err := errors.New("field client_id was not set on OAuthAuthorize form")
|
||||
return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice)
|
||||
}
|
||||
|
||||
if form.RedirectURI == "" {
|
||||
err := errors.New("field redirect_uri was not set on OAuthAuthorize form")
|
||||
return gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice)
|
||||
}
|
||||
|
||||
// set default scope to read
|
||||
// Set default scope to read.
|
||||
if form.Scope == "" {
|
||||
form.Scope = "read"
|
||||
}
|
||||
|
||||
// save these values from the form so we can use them elsewhere in the session
|
||||
// Save these values from the form so we
|
||||
// can use them elsewhere in the session.
|
||||
s.Set(sessionForceLogin, form.ForceLogin)
|
||||
s.Set(sessionResponseType, form.ResponseType)
|
||||
s.Set(sessionClientID, form.ClientID)
|
||||
|
@ -310,32 +255,43 @@ func saveAuthFormToSession(s sessions.Session, form *apimodel.OAuthAuthorize) gt
|
|||
s.Set(sessionInternalState, uuid.NewString())
|
||||
s.Set(sessionClientState, form.State)
|
||||
|
||||
if err := s.Save(); err != nil {
|
||||
err := fmt.Errorf("error saving form values onto session: %s", err)
|
||||
return gtserror.NewErrorInternalError(err, oauth.HelpfulAdvice)
|
||||
}
|
||||
|
||||
return nil
|
||||
m.mustSaveSession(s)
|
||||
c.Redirect(http.StatusSeeOther, "/auth"+AuthSignInPath)
|
||||
}
|
||||
|
||||
func ensureUserIsAuthorizedOrRedirect(ctx *gin.Context, user *gtsmodel.User, account *gtsmodel.Account) (redirected bool) {
|
||||
if user.ConfirmedAt.IsZero() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/auth"+AuthCheckYourEmailPath)
|
||||
redirected = true
|
||||
return
|
||||
}
|
||||
// validateUser checks if the given user:
|
||||
//
|
||||
// 1. Has a confirmed email address.
|
||||
// 2. Has been approved.
|
||||
// 3. Is not disabled or suspended.
|
||||
//
|
||||
// If all looks OK, returns true. Otherwise,
|
||||
// redirects to a help page and returns false.
|
||||
func (m *Module) validateUser(
|
||||
c *gin.Context,
|
||||
user *gtsmodel.User,
|
||||
) bool {
|
||||
switch {
|
||||
case user.ConfirmedAt.IsZero():
|
||||
// User email not confirmed yet.
|
||||
const redirectTo = "/auth" + AuthCheckYourEmailPath
|
||||
c.Redirect(http.StatusSeeOther, redirectTo)
|
||||
return false
|
||||
|
||||
if !*user.Approved {
|
||||
ctx.Redirect(http.StatusSeeOther, "/auth"+AuthWaitForApprovalPath)
|
||||
redirected = true
|
||||
return
|
||||
}
|
||||
case !*user.Approved:
|
||||
// User signup not approved yet.
|
||||
const redirectTo = "/auth" + AuthWaitForApprovalPath
|
||||
c.Redirect(http.StatusSeeOther, redirectTo)
|
||||
return false
|
||||
|
||||
if *user.Disabled || !account.SuspendedAt.IsZero() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/auth"+AuthAccountDisabledPath)
|
||||
redirected = true
|
||||
return
|
||||
}
|
||||
case *user.Disabled || !user.Account.SuspendedAt.IsZero():
|
||||
// User disabled or suspended.
|
||||
const redirectTo = "/auth" + AuthAccountDisabledPath
|
||||
c.Redirect(http.StatusSeeOther, redirectTo)
|
||||
return false
|
||||
|
||||
return
|
||||
default:
|
||||
// All good.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
|
||||
returnedInternalState := c.Query(callbackStateParam)
|
||||
if returnedInternalState == "" {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
err := fmt.Errorf("%s parameter not found on callback query", callbackStateParam)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
@ -69,14 +69,14 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
savedInternalStateI := s.Get(sessionInternalState)
|
||||
savedInternalState, ok := savedInternalStateI.(string)
|
||||
if !ok {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionInternalState)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if returnedInternalState != savedInternalState {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
err := errors.New("mismatch between callback state and saved state")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
@ -85,7 +85,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
// retrieve stored claims using code
|
||||
code := c.Query(callbackCodeParam)
|
||||
if code == "" {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
err := fmt.Errorf("%s parameter not found on callback query", callbackCodeParam)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
@ -93,7 +93,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
|
||||
claims, errWithCode := m.idp.HandleCallback(c.Request.Context(), code)
|
||||
if errWithCode != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
@ -102,15 +102,15 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
// info about the app associated with the client_id
|
||||
clientID, ok := s.Get(sessionClientID).(string)
|
||||
if !ok || clientID == "" {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionClientID)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
app, err := m.db.GetApplicationByClientID(c.Request.Context(), clientID)
|
||||
app, err := m.state.DB.GetApplicationByClientID(c.Request.Context(), clientID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
safe := fmt.Sprintf("application for %s %s could not be retrieved", sessionClientID, clientID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
|
@ -124,7 +124,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
|
||||
user, errWithCode := m.fetchUserForClaims(c.Request.Context(), claims, net.IP(c.ClientIP()), app.ID)
|
||||
if errWithCode != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
@ -140,7 +140,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
s.Set(sessionClaims, claims)
|
||||
s.Set(sessionAppID, app.ID)
|
||||
if err := s.Save(); err != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
@ -173,7 +173,7 @@ func (m *Module) CallbackGETHandler(c *gin.Context) {
|
|||
|
||||
s.Set(sessionUserID, user.ID)
|
||||
if err := s.Save(); err != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
@ -186,7 +186,7 @@ func (m *Module) FinalizePOSTHandler(c *gin.Context) {
|
|||
|
||||
form := &extraInfo{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
@ -219,7 +219,7 @@ func (m *Module) FinalizePOSTHandler(c *gin.Context) {
|
|||
}
|
||||
|
||||
// see if the username is still available
|
||||
usernameAvailable, err := m.db.IsUsernameAvailable(c.Request.Context(), form.Username)
|
||||
usernameAvailable, err := m.state.DB.IsUsernameAvailable(c.Request.Context(), form.Username)
|
||||
if err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
@ -248,7 +248,7 @@ func (m *Module) FinalizePOSTHandler(c *gin.Context) {
|
|||
// we're now ready to actually create the user
|
||||
user, errWithCode := m.createUserFromOIDC(c.Request.Context(), claims, form, net.IP(c.ClientIP()), appID)
|
||||
if errWithCode != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
@ -256,7 +256,7 @@ func (m *Module) FinalizePOSTHandler(c *gin.Context) {
|
|||
s.Delete(sessionAppID)
|
||||
s.Set(sessionUserID, user.ID)
|
||||
if err := s.Save(); err != nil {
|
||||
m.clearSession(s)
|
||||
m.mustClearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
@ -268,7 +268,7 @@ func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip
|
|||
err := errors.New("no sub claim found - is your provider OIDC compliant?")
|
||||
return nil, gtserror.NewErrorBadRequest(err, err.Error())
|
||||
}
|
||||
user, err := m.db.GetUserByExternalID(ctx, claims.Sub)
|
||||
user, err := m.state.DB.GetUserByExternalID(ctx, claims.Sub)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
@ -280,7 +280,7 @@ func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip
|
|||
return nil, nil
|
||||
}
|
||||
// fallback to email if we want to link existing users
|
||||
user, err = m.db.GetUserByEmailAddress(ctx, claims.Email)
|
||||
user, err = m.state.DB.GetUserByEmailAddress(ctx, claims.Email)
|
||||
if err == db.ErrNoEntries {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
|
@ -290,7 +290,7 @@ func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip
|
|||
// at this point we have found a matching user but still need to link the newly received external ID
|
||||
|
||||
user.ExternalID = claims.Sub
|
||||
err = m.db.UpdateUser(ctx, user, "external_id")
|
||||
err = m.state.DB.UpdateUser(ctx, user, "external_id")
|
||||
if err != nil {
|
||||
err := fmt.Errorf("error linking existing user %s: %s", claims.Email, err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
|
@ -300,7 +300,7 @@ func (m *Module) fetchUserForClaims(ctx context.Context, claims *oidc.Claims, ip
|
|||
|
||||
func (m *Module) createUserFromOIDC(ctx context.Context, claims *oidc.Claims, extraInfo *extraInfo, ip net.IP, appID string) (*gtsmodel.User, gtserror.WithCode) {
|
||||
// Check if the claimed email address is available for use.
|
||||
emailAvailable, err := m.db.IsEmailAvailable(ctx, claims.Email)
|
||||
emailAvailable, err := m.state.DB.IsEmailAvailable(ctx, claims.Email)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("db error checking email availability: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
|
@ -354,7 +354,7 @@ func (m *Module) createUserFromOIDC(ctx context.Context, claims *oidc.Claims, ex
|
|||
|
||||
// Create the user! This will also create an account and
|
||||
// store it in the database, so we don't need to do that.
|
||||
user, err := m.db.NewSignup(ctx, gtsmodel.NewSignup{
|
||||
user, err := m.state.DB.NewSignup(ctx, gtsmodel.NewSignup{
|
||||
Username: extraInfo.Username,
|
||||
Email: claims.Email,
|
||||
Password: password,
|
||||
|
|
|
@ -18,97 +18,56 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
func (m *Module) OobHandler(c *gin.Context) {
|
||||
// OOBTokenGETHandler parses the OAuth code from the query
|
||||
// params and serves a nice little HTML page showing the code.
|
||||
func (m *Module) OOBTokenGETHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
oobToken := c.Query("code")
|
||||
if oobToken == "" {
|
||||
const errText = "no 'code' query value provided in callback redirect"
|
||||
m.clearSessionWithBadRequest(c, s, errors.New(errText), errText)
|
||||
return
|
||||
}
|
||||
|
||||
user := m.mustUserFromSession(c, s)
|
||||
if user == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
scope := m.mustStringFromSession(c, s, sessionScope)
|
||||
if scope == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
// We're done with
|
||||
// the session now.
|
||||
m.mustClearSession(s)
|
||||
|
||||
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
instanceGet := func(ctx context.Context) (*apimodel.InstanceV1, gtserror.WithCode) {
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
oobToken := c.Query("code")
|
||||
if oobToken == "" {
|
||||
err := errors.New("no 'code' query value provided in callback redirect")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error(), oauth.HelpfulAdvice), instanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
s := sessions.Default(c)
|
||||
|
||||
errs := []string{}
|
||||
|
||||
scope, ok := s.Get(sessionScope).(string)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionScope))
|
||||
}
|
||||
|
||||
userID, ok := s.Get(sessionUserID).(string)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Sprintf("key %s was not found in session", sessionUserID))
|
||||
}
|
||||
|
||||
if len(errs) != 0 {
|
||||
errs = append(errs, oauth.HelpfulAdvice)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(errors.New("one or more missing keys on session during OobHandler"), errs...), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := m.db.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("user with id %s could not be retrieved", userID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, instanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := m.db.GetAccountByID(c.Request.Context(), user.AccountID)
|
||||
if err != nil {
|
||||
m.clearSession(s)
|
||||
safe := fmt.Sprintf("account with id %s could not be retrieved", user.AccountID)
|
||||
var errWithCode gtserror.WithCode
|
||||
if err == db.ErrNoEntries {
|
||||
errWithCode = gtserror.NewErrorBadRequest(err, safe, oauth.HelpfulAdvice)
|
||||
} else {
|
||||
errWithCode = gtserror.NewErrorInternalError(err, safe, oauth.HelpfulAdvice)
|
||||
}
|
||||
apiutil.ErrorHandler(c, errWithCode, instanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
// we're done with the session now, so just clear it out
|
||||
m.clearSession(s)
|
||||
|
||||
page := apiutil.WebPage{
|
||||
apiutil.TemplateWebPage(c, apiutil.WebPage{
|
||||
Template: "oob.tmpl",
|
||||
Instance: instance,
|
||||
Extra: map[string]any{
|
||||
"user": acct.Username,
|
||||
"user": user.Account.Username,
|
||||
"oobToken": oobToken,
|
||||
"scope": scope,
|
||||
},
|
||||
}
|
||||
|
||||
apiutil.TemplateWebPage(c, page)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -22,104 +22,143 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pquerna/otp/totp"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// signIn just wraps a form-submitted username (we want an email) and password
|
||||
type signIn struct {
|
||||
Email string `form:"username"`
|
||||
Password string `form:"password"`
|
||||
}
|
||||
|
||||
// SignInGETHandler should be served at https://example.org/auth/sign_in.
|
||||
// The idea is to present a sign in page to the user, where they can enter their username and password.
|
||||
// The form will then POST to the sign in page, which will be handled by SignInPOSTHandler.
|
||||
// If an idp provider is set, then the user will be redirected to that to do their sign in.
|
||||
// SignInGETHandler should be served at
|
||||
// GET https://example.org/auth/sign_in.
|
||||
//
|
||||
// The idea is to present a friendly sign-in
|
||||
// page to the user, where they can enter their
|
||||
// username and password.
|
||||
//
|
||||
// When submitted, the form will POST to the sign-
|
||||
// in page, which will be handled by SignInPOSTHandler.
|
||||
//
|
||||
// If an idp provider is set, then the user will
|
||||
// be redirected to that to do their sign in.
|
||||
func (m *Module) SignInGETHandler(c *gin.Context) {
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.HTMLAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if !config.GetOIDCEnabled() {
|
||||
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
if config.GetOIDCEnabled() {
|
||||
// IDP provider is in use, so redirect to it
|
||||
// instead of serving our own sign in page.
|
||||
//
|
||||
// We need the internal state to know where
|
||||
// to redirect to.
|
||||
internalState := m.mustStringFromSession(
|
||||
c,
|
||||
sessions.Default(c),
|
||||
sessionInternalState,
|
||||
)
|
||||
if internalState == "" {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
page := apiutil.WebPage{
|
||||
Template: "sign-in.tmpl",
|
||||
Instance: instance,
|
||||
}
|
||||
|
||||
apiutil.TemplateWebPage(c, page)
|
||||
c.Redirect(http.StatusSeeOther, m.idp.AuthCodeURL(internalState))
|
||||
return
|
||||
}
|
||||
|
||||
// idp provider is in use, so redirect to it
|
||||
s := sessions.Default(c)
|
||||
|
||||
internalStateI := s.Get(sessionInternalState)
|
||||
internalState, ok := internalStateI.(string)
|
||||
if !ok {
|
||||
m.clearSession(s)
|
||||
err := fmt.Errorf("key %s was not found in session", sessionInternalState)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusSeeOther, m.idp.AuthCodeURL(internalState))
|
||||
}
|
||||
|
||||
// SignInPOSTHandler should be served at https://example.org/auth/sign_in.
|
||||
// The idea is to present a sign in page to the user, where they can enter their username and password.
|
||||
// The handler will then redirect to the auth handler served at /auth
|
||||
func (m *Module) SignInPOSTHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
form := &signIn{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSession(s)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
userid, errWithCode := m.ValidatePassword(c.Request.Context(), form.Email, form.Password)
|
||||
// IDP provider is not in use.
|
||||
// Render our own cute little page.
|
||||
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
|
||||
if errWithCode != nil {
|
||||
// don't clear session here, so the user can just press back and try again
|
||||
// if they accidentally gave the wrong password or something
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
s.Set(sessionUserID, userid)
|
||||
if err := s.Save(); err != nil {
|
||||
err := fmt.Errorf("error saving user id onto session: %s", err)
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err, oauth.HelpfulAdvice), m.processor.InstanceGetV1)
|
||||
apiutil.TemplateWebPage(c, apiutil.WebPage{
|
||||
Template: "sign-in.tmpl",
|
||||
Instance: instance,
|
||||
})
|
||||
}
|
||||
|
||||
// SignInPOSTHandler should be served at
|
||||
// POST https://example.org/auth/sign_in.
|
||||
//
|
||||
// The handler will check the submitted credentials,
|
||||
// then redirect either to the 2fa form, or straight
|
||||
// to the authorize page served at /oauth/authorize.
|
||||
func (m *Module) SignInPOSTHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
// Parse email + password.
|
||||
form := &struct {
|
||||
Email string `form:"username" validate:"required"`
|
||||
Password string `form:"password" validate:"required"`
|
||||
}{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSessionWithBadRequest(c, s, err, oauth.HelpfulAdvice)
|
||||
return
|
||||
}
|
||||
|
||||
user, errWithCode := m.validatePassword(
|
||||
c.Request.Context(),
|
||||
form.Email,
|
||||
form.Password,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
// Don't clear session here yet, so the user
|
||||
// can just press back and try again if they
|
||||
// accidentally gave the wrong password, without
|
||||
// having to do the whole sign in flow again!
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// Whether or not 2fa is enabled, we want
|
||||
// to save the session when we're done here.
|
||||
defer m.mustSaveSession(s)
|
||||
|
||||
if user.TwoFactorEnabled() {
|
||||
// If this user has 2FA enabled, redirect
|
||||
// to the 2FA page and have them submit
|
||||
// a code from their authenticator app.
|
||||
s.Set(sessionUserIDAwaiting2FA, user.ID)
|
||||
c.Redirect(http.StatusFound, "/auth"+Auth2FAPath)
|
||||
return
|
||||
}
|
||||
|
||||
// If the user doesn't have 2fa enabled,
|
||||
// redirect straight to the OAuth authorize page.
|
||||
s.Set(sessionUserID, user.ID)
|
||||
c.Redirect(http.StatusFound, "/oauth"+OauthAuthorizePath)
|
||||
}
|
||||
|
||||
// ValidatePassword takes an email address and a password.
|
||||
// The goal is to authenticate the password against the one for that email
|
||||
// address stored in the database. If OK, we return the userid (a ulid) for that user,
|
||||
// so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db.
|
||||
func (m *Module) ValidatePassword(ctx context.Context, email string, password string) (string, gtserror.WithCode) {
|
||||
// validatePassword takes an email address and a password.
|
||||
// The func authenticates the password against the one for
|
||||
// that email address stored in the database.
|
||||
//
|
||||
// If OK, it returns the user, so that it can be used in
|
||||
// further OAuth flows to generate a token etc.
|
||||
func (m *Module) validatePassword(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
password string,
|
||||
) (*gtsmodel.User, gtserror.WithCode) {
|
||||
if email == "" || password == "" {
|
||||
err := errors.New("email or password was not provided")
|
||||
return incorrectPassword(err)
|
||||
}
|
||||
|
||||
user, err := m.db.GetUserByEmailAddress(ctx, email)
|
||||
user, err := m.state.DB.GetUserByEmailAddress(ctx, email)
|
||||
if err != nil {
|
||||
err := fmt.Errorf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err)
|
||||
return incorrectPassword(err)
|
||||
|
@ -130,17 +169,141 @@ func (m *Module) ValidatePassword(ctx context.Context, email string, password st
|
|||
return incorrectPassword(err)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil {
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.EncryptedPassword),
|
||||
byteutil.S2B(password),
|
||||
); err != nil {
|
||||
err := fmt.Errorf("password hash didn't match for user %s during sign in attempt: %s", user.Email, err)
|
||||
return incorrectPassword(err)
|
||||
}
|
||||
|
||||
return user.ID, nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// incorrectPassword wraps the given error in a gtserror.WithCode, and returns
|
||||
// only a generic 'safe' error message to the user, to not give any info away.
|
||||
func incorrectPassword(err error) (string, gtserror.WithCode) {
|
||||
safeErr := fmt.Errorf("password/email combination was incorrect")
|
||||
return "", gtserror.NewErrorUnauthorized(err, safeErr.Error(), oauth.HelpfulAdvice)
|
||||
func incorrectPassword(err error) (*gtsmodel.User, gtserror.WithCode) {
|
||||
const errText = "password/email combination was incorrect"
|
||||
return nil, gtserror.NewErrorUnauthorized(err, errText, oauth.HelpfulAdvice)
|
||||
}
|
||||
|
||||
// TwoFactorCodeGETHandler should be served at
|
||||
// GET https://example.org/auth/2fa.
|
||||
//
|
||||
// The 2fa template displays a simple form asking the
|
||||
// user to input a code from their authenticator app.
|
||||
func (m *Module) TwoFactorCodeGETHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
user := m.mustUserFromSession(c, s)
|
||||
if user == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
instance, errWithCode := m.processor.InstanceGetV1(c.Request.Context())
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.TemplateWebPage(c, apiutil.WebPage{
|
||||
Template: "2fa.tmpl",
|
||||
Instance: instance,
|
||||
Extra: map[string]any{
|
||||
"user": user.Account.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TwoFactorCodePOSTHandler should be served at
|
||||
// POST https://example.org/auth/2fa.
|
||||
//
|
||||
// The idea is to handle a submitted 2fa code, validate it,
|
||||
// and if valid redirect to the /oauth/authorize page that
|
||||
// the user would get to if they didn't have 2fa enabled.
|
||||
func (m *Module) TwoFactorCodePOSTHandler(c *gin.Context) {
|
||||
s := sessions.Default(c)
|
||||
|
||||
user := m.mustUserFromSession(c, s)
|
||||
if user == nil {
|
||||
// Error already
|
||||
// written.
|
||||
return
|
||||
}
|
||||
|
||||
// Parse 2fa code.
|
||||
form := &struct {
|
||||
Code string `form:"code" validate:"required"`
|
||||
}{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
m.clearSessionWithBadRequest(c, s, err, oauth.HelpfulAdvice)
|
||||
return
|
||||
}
|
||||
|
||||
valid, err := m.validate2FACode(c, user, form.Code)
|
||||
if err != nil {
|
||||
m.clearSessionWithInternalError(c, s, err, oauth.HelpfulAdvice)
|
||||
return
|
||||
}
|
||||
|
||||
if !valid {
|
||||
// Don't clear session here yet, so the user
|
||||
// can just press back and try again if they
|
||||
// accidentally gave the wrong code, without
|
||||
// having to do the whole sign in flow again!
|
||||
const errText = "2fa code invalid or timed out, press back and try again; " +
|
||||
"if issues persist, pester your instance admin to check the server clock"
|
||||
errWithCode := gtserror.NewErrorBadRequest(errors.New(errText), errText)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
// Code looks good! Redirect
|
||||
// to the OAuth authorize page.
|
||||
s.Set(sessionUserID, user.ID)
|
||||
m.mustSaveSession(s)
|
||||
c.Redirect(http.StatusFound, "/oauth"+OauthAuthorizePath)
|
||||
}
|
||||
|
||||
func (m *Module) validate2FACode(c *gin.Context, user *gtsmodel.User, code string) (bool, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) <= 6 {
|
||||
// This is a normal authenticator
|
||||
// app code, just try to validate it.
|
||||
return totp.Validate(code, user.TwoFactorSecret), nil
|
||||
}
|
||||
|
||||
// This is a one-time recovery code.
|
||||
// Check against the user's stored codes.
|
||||
for i := 0; i < len(user.TwoFactorBackups); i++ {
|
||||
err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.TwoFactorBackups[i]),
|
||||
byteutil.S2B(code),
|
||||
)
|
||||
if err != nil {
|
||||
// Doesn't match,
|
||||
// try next.
|
||||
continue
|
||||
}
|
||||
|
||||
// We have a match.
|
||||
// Remove this one-time code from the user's backups.
|
||||
user.TwoFactorBackups = slices.Delete(user.TwoFactorBackups, i, i+1)
|
||||
if err := m.state.DB.UpdateUser(
|
||||
c.Request.Context(),
|
||||
user,
|
||||
"two_factor_backups",
|
||||
); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// So valid bestie!
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Not a valid one-time
|
||||
// recovery code.
|
||||
return false, nil
|
||||
}
|
||||
|
|
152
internal/api/auth/util.go
Normal file
152
internal/api/auth/util.go
Normal file
|
@ -0,0 +1,152 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||
)
|
||||
|
||||
func (m *Module) mustClearSession(s sessions.Session) {
|
||||
s.Clear()
|
||||
m.mustSaveSession(s)
|
||||
}
|
||||
|
||||
func (m *Module) mustSaveSession(s sessions.Session) {
|
||||
if err := s.Save(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustUserFromSession returns a *gtsmodel.User by checking the
|
||||
// session for a user id and fetching the user from the database.
|
||||
//
|
||||
// On failure, the function clears session state, writes an internal
|
||||
// error to the response writer, and returns nil. Callers should always
|
||||
// return immediately if receiving nil back from this function!
|
||||
func (m *Module) mustUserFromSession(
|
||||
c *gin.Context,
|
||||
s sessions.Session,
|
||||
) *gtsmodel.User {
|
||||
// Try "userid" key first, fall
|
||||
// back to "userid_awaiting_2fa".
|
||||
var userID string
|
||||
for _, key := range [2]string{
|
||||
sessionUserID,
|
||||
sessionUserIDAwaiting2FA,
|
||||
} {
|
||||
var ok bool
|
||||
userID, ok = s.Get(key).(string)
|
||||
if ok && userID != "" {
|
||||
// Got it.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
const safe = "neither userid nor userid_awaiting_2fa keys found in session"
|
||||
m.clearSessionWithInternalError(c, s, errors.New(safe), safe, oauth.HelpfulAdvice)
|
||||
return nil
|
||||
}
|
||||
|
||||
user, err := m.state.DB.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
safe := "db error getting user " + userID
|
||||
m.clearSessionWithInternalError(c, s, err, safe, oauth.HelpfulAdvice)
|
||||
return nil
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// mustAppFromSession returns a *gtsmodel.Application by checking the
|
||||
// session for an application keyid and fetching the app from the database.
|
||||
//
|
||||
// On failure, the function clears session state, writes an internal
|
||||
// error to the response writer, and returns nil. Callers should always
|
||||
// return immediately if receiving nil back from this function!
|
||||
func (m *Module) mustAppFromSession(
|
||||
c *gin.Context,
|
||||
s sessions.Session,
|
||||
) *gtsmodel.Application {
|
||||
clientID, ok := s.Get(sessionClientID).(string)
|
||||
if !ok {
|
||||
const safe = "key client_id not found in session"
|
||||
m.clearSessionWithInternalError(c, s, errors.New(safe), safe, oauth.HelpfulAdvice)
|
||||
return nil
|
||||
}
|
||||
|
||||
app, err := m.state.DB.GetApplicationByClientID(c.Request.Context(), clientID)
|
||||
if err != nil {
|
||||
safe := "db error getting app for clientID " + clientID
|
||||
m.clearSessionWithInternalError(c, s, err, safe, oauth.HelpfulAdvice)
|
||||
return nil
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// mustStringFromSession returns the string value
|
||||
// corresponding to the given session key, if any is set.
|
||||
//
|
||||
// On failure (nothing set), the function clears session
|
||||
// state, writes an internal error to the response writer,
|
||||
// and returns nil. Callers should always return immediately
|
||||
// if receiving nil back from this function!
|
||||
func (m *Module) mustStringFromSession(
|
||||
c *gin.Context,
|
||||
s sessions.Session,
|
||||
key string,
|
||||
) string {
|
||||
v, ok := s.Get(key).(string)
|
||||
if !ok {
|
||||
safe := "key " + key + " not found in session"
|
||||
m.clearSessionWithInternalError(c, s, errors.New(safe), safe, oauth.HelpfulAdvice)
|
||||
return ""
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *Module) clearSessionWithInternalError(
|
||||
c *gin.Context,
|
||||
s sessions.Session,
|
||||
err error,
|
||||
helpText ...string,
|
||||
) {
|
||||
m.mustClearSession(s)
|
||||
errWithCode := gtserror.NewErrorInternalError(err, helpText...)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
}
|
||||
|
||||
func (m *Module) clearSessionWithBadRequest(
|
||||
c *gin.Context,
|
||||
s sessions.Session,
|
||||
err error,
|
||||
helpText ...string,
|
||||
) {
|
||||
m.mustClearSession(s)
|
||||
errWithCode := gtserror.NewErrorBadRequest(err, helpText...)
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
}
|
|
@ -21,6 +21,7 @@ import (
|
|||
"errors"
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/gin-gonic/gin"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
|
@ -87,7 +88,10 @@ func (m *Module) AccountDeletePOSTHandler(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(authed.User.EncryptedPassword), []byte(form.Password)); err != nil {
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(authed.User.EncryptedPassword),
|
||||
byteutil.S2B(form.Password),
|
||||
); err != nil {
|
||||
err = errors.New("invalid password provided in account delete request")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
|
|
|
@ -23,6 +23,7 @@ import (
|
|||
"net/http"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/user"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
|
@ -50,11 +51,17 @@ func (suite *PasswordChangeTestSuite) TestPasswordChangePOST() {
|
|||
}
|
||||
|
||||
// new password should pass
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("peepeepoopoopassword"))
|
||||
err = bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(dbUser.EncryptedPassword),
|
||||
byteutil.S2B("peepeepoopoopassword"),
|
||||
)
|
||||
suite.NoError(err)
|
||||
|
||||
// old password should fail
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password"))
|
||||
err = bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(dbUser.EncryptedPassword),
|
||||
byteutil.S2B("password"),
|
||||
)
|
||||
suite.EqualError(err, "crypto/bcrypt: hashedPassword is not the hash of the given password")
|
||||
}
|
||||
|
||||
|
|
353
internal/api/client/user/twofactor.go
Normal file
353
internal/api/client/user/twofactor.go
Normal file
|
@ -0,0 +1,353 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
const OIDCTwoFactorHelp = "two factor authentication request cannot be processed by GoToSocial as this instance is running with OIDC enabled; you must use 2FA provided by your OIDC provider"
|
||||
|
||||
// TwoFactorQRCodePngGETHandler swagger:operation GET /api/v1/user/2fa/qr.png TwoFactorQRCodePngGet
|
||||
//
|
||||
// Return a QR code png to allow the authorized user to enable 2fa for their login.
|
||||
//
|
||||
// For the plaintext version of the QR code URI, call /api/v1/user/2fa/qruri instead.
|
||||
//
|
||||
// If 2fa is already enabled for this user, the QR code (with its secret) will not be shared again. Instead, code 409 Conflict will be returned. To get a fresh secret, first disable 2fa using POST /api/v1/user/2fa/disable, and then call this endpoint again.
|
||||
//
|
||||
// If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - user
|
||||
//
|
||||
// produces:
|
||||
// - image/png
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: QR code png
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '409':
|
||||
// description: conflict
|
||||
// '422':
|
||||
// description: unprocessable entity
|
||||
// '500':
|
||||
// description: internal error
|
||||
func (m *Module) TwoFactorQRCodePngGETHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeReadAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, "image/png"); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if config.GetOIDCEnabled() {
|
||||
err := errors.New("instance running with OIDC")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, OIDCTwoFactorHelp), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
content, errWithCode := m.processor.User().TwoFactorQRCodePngGet(c.Request.Context(), authed.User)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Close content when we're done, catch errors.
|
||||
if err := content.Content.Close(); err != nil {
|
||||
log.Errorf(c.Request.Context(), "error closing readcloser: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
c.DataFromReader(
|
||||
http.StatusOK,
|
||||
content.ContentLength,
|
||||
content.ContentType,
|
||||
content.Content,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// TwoFactorQRCodeURIGETHandler swagger:operation GET /api/v1/user/2fa/qruri TwoFactorQRCodeURIGet
|
||||
//
|
||||
// Return a QR code uri to allow the authorized user to enable 2fa for their login.
|
||||
//
|
||||
// For a png of the QR code, call /api/v1/user/2fa/qr.png instead.
|
||||
//
|
||||
// If 2fa is already enabled for this user, the QR code URI (with its secret) will not be shared again. Instead, code 409 Conflict will be returned. To get a fresh secret, first disable 2fa using POST /api/v1/user/2fa/disable, and then call this endpoint again.
|
||||
//
|
||||
// If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - user
|
||||
//
|
||||
// produces:
|
||||
// - text/plain
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - read:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: QR code uri
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '409':
|
||||
// description: conflict
|
||||
// '422':
|
||||
// description: unprocessable entity
|
||||
// '500':
|
||||
// description: internal error
|
||||
func (m *Module) TwoFactorQRCodeURIGETHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeReadAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.TextPlain); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if config.GetOIDCEnabled() {
|
||||
err := errors.New("instance running with OIDC")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, OIDCTwoFactorHelp), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
uri, errWithCode := m.processor.User().TwoFactorQRCodeURIGet(c.Request.Context(), authed.User)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.Data(
|
||||
c,
|
||||
http.StatusOK,
|
||||
apiutil.TextPlain,
|
||||
[]byte(uri.String()),
|
||||
)
|
||||
}
|
||||
|
||||
// TwoFactorEnablePOSTHandler swagger:operation POST /api/v1/user/2fa/enable TwoFactorEnablePost
|
||||
//
|
||||
// Enable 2fa for the authorized user, using the provided code from an authenticator app, and return an array of one-time recovery codes to allow bypassing 2fa.
|
||||
//
|
||||
// If 2fa is already enabled for this user, code 409 Conflict will be returned.
|
||||
//
|
||||
// If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - user
|
||||
//
|
||||
// consumes:
|
||||
// - application/json
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// produces:
|
||||
// - application/json
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: code
|
||||
// type: string
|
||||
// description: |-
|
||||
// 2fa code from the user's authenticator app.
|
||||
// Sample: 123456
|
||||
// in: formData
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: QR code
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '409':
|
||||
// description: conflict
|
||||
// '422':
|
||||
// description: unprocessable entity
|
||||
// '500':
|
||||
// description: internal error
|
||||
func (m *Module) TwoFactorEnablePOSTHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeWriteAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if config.GetOIDCEnabled() {
|
||||
err := errors.New("instance running with OIDC")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, OIDCPasswordHelp), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
form := &struct {
|
||||
Code string `json:"code" form:"code" validation:"required"`
|
||||
}{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
recoveryCodes, errWithCode := m.processor.User().TwoFactorEnable(
|
||||
c.Request.Context(),
|
||||
authed.User,
|
||||
form.Code,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
apiutil.JSON(c, http.StatusOK, recoveryCodes)
|
||||
}
|
||||
|
||||
// TwoFactorDisablePOSTHandler swagger:operation POST /api/v1/user/2fa/disable TwoFactorDisablePost
|
||||
//
|
||||
// Disable 2fa for the authorized user. User's current password must be provided for verification purposes.
|
||||
//
|
||||
// If 2fa is already disabled for this user, code 409 Conflict will be returned.
|
||||
//
|
||||
// If the instance is running with OIDC enabled, two factor authentication cannot be turned on or off in GtS, it must be enabled or disabled using the OIDC provider. All calls to 2fa api endpoints will return 422 Unprocessable Entity while OIDC is enabled.
|
||||
//
|
||||
// ---
|
||||
// tags:
|
||||
// - user
|
||||
//
|
||||
// consumes:
|
||||
// - application/json
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// parameters:
|
||||
// -
|
||||
// name: password
|
||||
// type: string
|
||||
// description: User's current password, for verification.
|
||||
// in: formData
|
||||
//
|
||||
// security:
|
||||
// - OAuth2 Bearer:
|
||||
// - write:accounts
|
||||
//
|
||||
// responses:
|
||||
// '200':
|
||||
// description: QR code
|
||||
// '401':
|
||||
// description: unauthorized
|
||||
// '403':
|
||||
// description: forbidden
|
||||
// '406':
|
||||
// description: not acceptable
|
||||
// '409':
|
||||
// description: conflict
|
||||
// '422':
|
||||
// description: unprocessable entity
|
||||
// '500':
|
||||
// description: internal error
|
||||
func (m *Module) TwoFactorDisablePOSTHandler(c *gin.Context) {
|
||||
authed, errWithCode := apiutil.TokenAuth(c,
|
||||
true, true, true, true,
|
||||
apiutil.ScopeWriteAccounts,
|
||||
)
|
||||
if errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if config.GetOIDCEnabled() {
|
||||
err := errors.New("instance running with OIDC")
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorUnprocessableEntity(err, OIDCPasswordHelp), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
form := &struct {
|
||||
Password string `json:"password" form:"password" validation:"required"`
|
||||
}{}
|
||||
if err := c.ShouldBind(form); err != nil {
|
||||
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
if errWithCode := m.processor.User().TwoFactorDisable(
|
||||
c.Request.Context(),
|
||||
authed.User,
|
||||
form.Password,
|
||||
); errWithCode != nil {
|
||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
|
@ -25,12 +25,14 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// BasePath is the base URI path for this module, minus the 'api' prefix
|
||||
BasePath = "/v1/user"
|
||||
// PasswordChangePath is the path for POSTing a password change request.
|
||||
PasswordChangePath = BasePath + "/password_change"
|
||||
// EmailChangePath is the path for POSTing an email address change request.
|
||||
EmailChangePath = BasePath + "/email_change"
|
||||
BasePath = "/v1/user"
|
||||
PasswordChangePath = BasePath + "/password_change"
|
||||
EmailChangePath = BasePath + "/email_change"
|
||||
TwoFactorPath = BasePath + "/2fa"
|
||||
TwoFactorQRCodePngPath = TwoFactorPath + "/qr.png"
|
||||
TwoFactorQRCodeURIPath = TwoFactorPath + "/qruri"
|
||||
TwoFactorEnablePath = TwoFactorPath + "/enable"
|
||||
TwoFactorDisablePath = TwoFactorPath + "/disable"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
|
@ -47,4 +49,8 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H
|
|||
attachHandler(http.MethodGet, BasePath, m.UserGETHandler)
|
||||
attachHandler(http.MethodPost, PasswordChangePath, m.PasswordChangePOSTHandler)
|
||||
attachHandler(http.MethodPost, EmailChangePath, m.EmailChangePOSTHandler)
|
||||
attachHandler(http.MethodGet, TwoFactorQRCodePngPath, m.TwoFactorQRCodePngGETHandler)
|
||||
attachHandler(http.MethodGet, TwoFactorQRCodeURIPath, m.TwoFactorQRCodeURIGETHandler)
|
||||
attachHandler(http.MethodPost, TwoFactorEnablePath, m.TwoFactorEnablePOSTHandler)
|
||||
attachHandler(http.MethodPost, TwoFactorDisablePath, m.TwoFactorDisablePOSTHandler)
|
||||
}
|
||||
|
|
|
@ -22,13 +22,13 @@ type OAuthAuthorize struct {
|
|||
// Forces the user to re-login, which is necessary for authorizing with multiple accounts from the same instance.
|
||||
ForceLogin string `form:"force_login" json:"force_login"`
|
||||
// Should be set equal to `code`.
|
||||
ResponseType string `form:"response_type" json:"response_type"`
|
||||
ResponseType string `form:"response_type" json:"response_type" validate:"required"`
|
||||
// Client ID, obtained during app registration.
|
||||
ClientID string `form:"client_id" json:"client_id"`
|
||||
ClientID string `form:"client_id" json:"client_id" validate:"required"`
|
||||
// Set a URI to redirect the user to.
|
||||
// If this parameter is set to urn:ietf:wg:oauth:2.0:oob then the authorization code will be shown instead.
|
||||
// Must match one of the redirect URIs declared during app registration.
|
||||
RedirectURI string `form:"redirect_uri" json:"redirect_uri"`
|
||||
RedirectURI string `form:"redirect_uri" json:"redirect_uri" validate:"required"`
|
||||
// List of requested OAuth scopes, separated by spaces (or by pluses, if using query parameters).
|
||||
// Must be a subset of scopes declared during app registration. If not provided, defaults to read.
|
||||
Scope string `form:"scope" json:"scope"`
|
||||
|
|
|
@ -60,6 +60,9 @@ type User struct {
|
|||
// Time when the last "please reset your password" email was sent, if at all. (ISO 8601 Datetime)
|
||||
// example: 2021-07-30T09:20:25+00:00
|
||||
ResetPasswordSentAt string `json:"reset_password_sent_at,omitempty"`
|
||||
// Time at which 2fa was enabled for this user. (ISO 8601 Datetime)
|
||||
// example: 2021-07-30T09:20:25+00:00
|
||||
TwoFactorEnabledAt string `json:"two_factor_enabled_at,omitempty"`
|
||||
}
|
||||
|
||||
// PasswordChangeRequest models user password change parameters.
|
||||
|
|
70
internal/db/bundb/migrations/20250324173534_2fa.go
Normal file
70
internal/db/bundb/migrations/20250324173534_2fa.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
newmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20250324173534_2fa"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
up := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
log.Info(ctx, "adding new 2fa columns to user table...")
|
||||
|
||||
var newUser *newmodel.User
|
||||
newUserType := reflect.TypeOf(newUser)
|
||||
|
||||
for _, column := range []string{
|
||||
"TwoFactorSecret",
|
||||
"TwoFactorBackups",
|
||||
"TwoFactorEnabledAt",
|
||||
} {
|
||||
// Generate new column definition from bun.
|
||||
colDef, err := getBunColumnDef(tx, newUserType, column)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making column def: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.
|
||||
NewAddColumn().
|
||||
Model(newUser).
|
||||
ColumnExpr(colDef).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error adding column: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
down := func(ctx context.Context, db *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := Migrations.Register(up, down); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
52
internal/db/bundb/migrations/20250324173534_2fa/user.go
Normal file
52
internal/db/bundb/migrations/20250324173534_2fa/user.go
Normal file
|
@ -0,0 +1,52 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package gtsmodel
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
Email string `bun:",nullzero,unique"`
|
||||
AccountID string `bun:"type:CHAR(26),nullzero,notnull,unique"`
|
||||
EncryptedPassword string `bun:",nullzero,notnull"`
|
||||
TwoFactorSecret string `bun:",nullzero"`
|
||||
TwoFactorBackups []string `bun:",nullzero,array"`
|
||||
TwoFactorEnabledAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
SignUpIP net.IP `bun:",nullzero"`
|
||||
InviteID string `bun:"type:CHAR(26),nullzero"`
|
||||
Reason string `bun:",nullzero"`
|
||||
Locale string `bun:",nullzero"`
|
||||
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"`
|
||||
LastEmailedAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
ConfirmationToken string `bun:",nullzero"`
|
||||
ConfirmationSentAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
ConfirmedAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
UnconfirmedEmail string `bun:",nullzero"`
|
||||
Moderator *bool `bun:",nullzero,notnull,default:false"`
|
||||
Admin *bool `bun:",nullzero,notnull,default:false"`
|
||||
Disabled *bool `bun:",nullzero,notnull,default:false"`
|
||||
Approved *bool `bun:",nullzero,notnull,default:false"`
|
||||
ResetPasswordToken string `bun:",nullzero"`
|
||||
ResetPasswordSentAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
ExternalID string `bun:",nullzero,unique"`
|
||||
}
|
|
@ -31,49 +31,163 @@ import (
|
|||
// Sign-ups that have been denied rather than
|
||||
// approved are stored as DeniedUser instead.
|
||||
type User struct {
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
|
||||
Email string `bun:",nullzero,unique"` // confirmed email address for this user, this should be unique -- only one email address registered per instance, multiple users per email are not supported
|
||||
AccountID string `bun:"type:CHAR(26),nullzero,notnull,unique"` // The id of the local gtsmodel.Account entry for this user.
|
||||
Account *Account `bun:"rel:belongs-to"` // Pointer to the account of this user that corresponds to AccountID.
|
||||
EncryptedPassword string `bun:",nullzero,notnull"` // The encrypted password of this user, generated using https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword. A salt is included so we're safe against 🌈 tables.
|
||||
SignUpIP net.IP `bun:",nullzero"` // IP this user used to sign up. Only stored for pending sign-ups.
|
||||
InviteID string `bun:"type:CHAR(26),nullzero"` // id of the user who invited this user (who let this joker in?)
|
||||
Reason string `bun:",nullzero"` // What reason was given for signing up when this user was created?
|
||||
Locale string `bun:",nullzero"` // In what timezone/locale is this user located?
|
||||
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"` // Which application id created this user? See gtsmodel.Application
|
||||
CreatedByApplication *Application `bun:"rel:belongs-to"` // Pointer to the application corresponding to createdbyapplicationID.
|
||||
LastEmailedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this user last contacted by email.
|
||||
ConfirmationToken string `bun:",nullzero"` // What confirmation token did we send this user/what are we expecting back?
|
||||
ConfirmationSentAt time.Time `bun:"type:timestamptz,nullzero"` // When did we send email confirmation to this user?
|
||||
ConfirmedAt time.Time `bun:"type:timestamptz,nullzero"` // When did the user confirm their email address
|
||||
UnconfirmedEmail string `bun:",nullzero"` // Email address that hasn't yet been confirmed
|
||||
Moderator *bool `bun:",nullzero,notnull,default:false"` // Is this user a moderator?
|
||||
Admin *bool `bun:",nullzero,notnull,default:false"` // Is this user an admin?
|
||||
Disabled *bool `bun:",nullzero,notnull,default:false"` // Is this user disabled from posting?
|
||||
Approved *bool `bun:",nullzero,notnull,default:false"` // Has this user been approved by a moderator?
|
||||
ResetPasswordToken string `bun:",nullzero"` // The generated token that the user can use to reset their password
|
||||
ResetPasswordSentAt time.Time `bun:"type:timestamptz,nullzero"` // When did we email the user their reset-password email?
|
||||
ExternalID string `bun:",nullzero,unique"` // If the login for the user is managed externally (e.g OIDC), we need to keep a stable reference to the external object (e.g OIDC sub claim)
|
||||
// Database ID of the user.
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
|
||||
|
||||
// Datetime when the user was created.
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
|
||||
// Datetime when was the user was last updated.
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
|
||||
// Confirmed email address for this user.
|
||||
//
|
||||
// This should be unique, ie., only one email
|
||||
// address registered per instance. Multiple
|
||||
// users per email are not (yet) supported.
|
||||
Email string `bun:",nullzero,unique"`
|
||||
|
||||
// Database ID of the Account for this user.
|
||||
AccountID string `bun:"type:CHAR(26),nullzero,notnull,unique"`
|
||||
|
||||
// Account corresponding to AccountID.
|
||||
Account *Account `bun:"-"`
|
||||
|
||||
// Bcrypt-encrypted password of this user, generated using
|
||||
// https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword.
|
||||
//
|
||||
// A salt is included so we're safe against 🌈 tables.
|
||||
EncryptedPassword string `bun:",nullzero,notnull"`
|
||||
|
||||
// 2FA secret for this user.
|
||||
//
|
||||
// Null if 2FA is not enabled for this user.
|
||||
TwoFactorSecret string `bun:",nullzero"`
|
||||
|
||||
// Slice of bcrypt-encrypted backup/recovery codes that a
|
||||
// user can use if they lose their 2FA authenticator app.
|
||||
//
|
||||
// Null if 2FA is not enabled for this user.
|
||||
TwoFactorBackups []string `bun:",nullzero,array"`
|
||||
|
||||
// Datetime when 2fa was enabled.
|
||||
//
|
||||
// Null if 2fa is not enabled for this user.
|
||||
TwoFactorEnabledAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
|
||||
// IP this user used to sign up.
|
||||
//
|
||||
// Only stored for pending sign-ups.
|
||||
SignUpIP net.IP `bun:",nullzero"`
|
||||
|
||||
// Database ID of the invite that this
|
||||
// user used to sign up, if applicable.
|
||||
InviteID string `bun:"type:CHAR(26),nullzero"`
|
||||
|
||||
// Reason given for signing up
|
||||
// when this user was created.
|
||||
Reason string `bun:",nullzero"`
|
||||
|
||||
// Timezone/locale in which
|
||||
// this user is located.
|
||||
Locale string `bun:",nullzero"`
|
||||
|
||||
// Database ID of the Application used to create this user.
|
||||
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"`
|
||||
|
||||
// Application corresponding to ApplicationID.
|
||||
CreatedByApplication *Application `bun:"-"`
|
||||
|
||||
// Datetime when this user was last contacted by email.
|
||||
LastEmailedAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
|
||||
// Confirmation token emailed to this user.
|
||||
//
|
||||
// Only set if user's email not yet confirmed.
|
||||
ConfirmationToken string `bun:",nullzero"`
|
||||
|
||||
// Datetime when confirmation token was emailed to user.
|
||||
ConfirmationSentAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
|
||||
// Datetime when user confirmed
|
||||
// their email address, if applicable.
|
||||
ConfirmedAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
|
||||
// Email address that hasn't yet been confirmed.
|
||||
UnconfirmedEmail string `bun:",nullzero"`
|
||||
|
||||
// True if user has moderator role.
|
||||
Moderator *bool `bun:",nullzero,notnull,default:false"`
|
||||
|
||||
// True if user has admin role.
|
||||
Admin *bool `bun:",nullzero,notnull,default:false"`
|
||||
|
||||
// True if user is disabled from posting.
|
||||
Disabled *bool `bun:",nullzero,notnull,default:false"`
|
||||
|
||||
// True if this user's sign up has
|
||||
// been approved by a moderator or admin.
|
||||
Approved *bool `bun:",nullzero,notnull,default:false"`
|
||||
|
||||
// Reset password token that the user
|
||||
// can use to reset their password.
|
||||
ResetPasswordToken string `bun:",nullzero"`
|
||||
|
||||
// Datetime when reset password token was emailed to user.
|
||||
ResetPasswordSentAt time.Time `bun:"type:timestamptz,nullzero"`
|
||||
|
||||
// If the login for the user is managed
|
||||
// externally (e.g., via OIDC), this is a stable
|
||||
// reference to the external object (e.g OIDC sub claim).
|
||||
ExternalID string `bun:",nullzero,unique"`
|
||||
}
|
||||
|
||||
func (u *User) TwoFactorEnabled() bool {
|
||||
return !u.TwoFactorEnabledAt.IsZero()
|
||||
}
|
||||
|
||||
// DeniedUser represents one user sign-up that
|
||||
// was submitted to the instance and denied.
|
||||
type DeniedUser struct {
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated
|
||||
Email string `bun:",nullzero,notnull"` // Email address provided on the sign-up form.
|
||||
Username string `bun:",nullzero,notnull"` // Username provided on the sign-up form.
|
||||
SignUpIP net.IP `bun:",nullzero"` // IP address the sign-up originated from.
|
||||
InviteID string `bun:"type:CHAR(26),nullzero"` // Invite ID provided on the sign-up form (if applicable).
|
||||
Locale string `bun:",nullzero"` // Locale provided on the sign-up form.
|
||||
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"` // ID of application used to create this sign-up.
|
||||
SignUpReason string `bun:",nullzero"` // Reason provided by user on the sign-up form.
|
||||
PrivateComment string `bun:",nullzero"` // Comment from instance admin about why this sign-up was denied.
|
||||
SendEmail *bool `bun:",nullzero,notnull,default:false"` // Send an email informing user that their sign-up has been denied.
|
||||
Message string `bun:",nullzero"` // Message to include when sending an email to the denied user's email address, if SendEmail is true.
|
||||
// Database ID of the user.
|
||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
|
||||
|
||||
// Datetime when the user was denied.
|
||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
|
||||
// Datetime when the denied user was last updated.
|
||||
UpdatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
|
||||
|
||||
// Email address provided on the sign-up form.
|
||||
Email string `bun:",nullzero,notnull"`
|
||||
|
||||
// Username provided on the sign-up form.
|
||||
Username string `bun:",nullzero,notnull"`
|
||||
|
||||
// IP address the sign-up originated from.
|
||||
SignUpIP net.IP `bun:",nullzero"`
|
||||
|
||||
// Invite ID provided on the sign-up form (if applicable).
|
||||
InviteID string `bun:"type:CHAR(26),nullzero"`
|
||||
|
||||
// Locale provided on the sign-up form.
|
||||
Locale string `bun:",nullzero"`
|
||||
|
||||
// ID of application used to create this sign-up.
|
||||
CreatedByApplicationID string `bun:"type:CHAR(26),nullzero"`
|
||||
|
||||
// Reason provided by user on the sign-up form.
|
||||
SignUpReason string `bun:",nullzero"`
|
||||
|
||||
// Comment from instance admin about why this sign-up was denied.
|
||||
PrivateComment string `bun:",nullzero"`
|
||||
|
||||
// Send an email informing user that their sign-up has been denied.
|
||||
SendEmail *bool `bun:",nullzero,notnull,default:false"`
|
||||
|
||||
// Message to include when sending an email to the
|
||||
// denied user's email address, if SendEmail is true.
|
||||
Message string `bun:",nullzero"`
|
||||
}
|
||||
|
||||
// NewSignup models parameters for the creation
|
||||
|
|
|
@ -25,6 +25,7 @@ import (
|
|||
"slices"
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
|
@ -70,8 +71,8 @@ func (p *Processor) MoveSelf(
|
|||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
[]byte(authed.User.EncryptedPassword),
|
||||
[]byte(form.Password),
|
||||
byteutil.S2B(authed.User.EncryptedPassword),
|
||||
byteutil.S2B(form.Password),
|
||||
); err != nil {
|
||||
const text = "invalid password provided in Move request"
|
||||
return gtserror.NewErrorBadRequest(errors.New(text), text)
|
||||
|
|
|
@ -23,6 +23,7 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
|
@ -41,7 +42,10 @@ func (p *Processor) EmailChange(
|
|||
newEmail string,
|
||||
) (*apimodel.User, gtserror.WithCode) {
|
||||
// Ensure provided password is correct.
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(password)); err != nil {
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.EncryptedPassword),
|
||||
byteutil.S2B(password),
|
||||
); err != nil {
|
||||
err := gtserror.Newf("%w", err)
|
||||
return nil, gtserror.NewErrorUnauthorized(err, "password was incorrect")
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ package user
|
|||
import (
|
||||
"context"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/validate"
|
||||
|
@ -29,7 +30,10 @@ import (
|
|||
// PasswordChange processes a password change request for the given user.
|
||||
func (p *Processor) PasswordChange(ctx context.Context, user *gtsmodel.User, oldPassword string, newPassword string) gtserror.WithCode {
|
||||
// Ensure provided oldPassword is the correct current password.
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte(oldPassword)); err != nil {
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.EncryptedPassword),
|
||||
byteutil.S2B(oldPassword),
|
||||
); err != nil {
|
||||
err := gtserror.Newf("%w", err)
|
||||
return gtserror.NewErrorUnauthorized(err, "old password was incorrect")
|
||||
}
|
||||
|
@ -48,7 +52,7 @@ func (p *Processor) PasswordChange(ctx context.Context, user *gtsmodel.User, old
|
|||
|
||||
// Hash the new password.
|
||||
encryptedPassword, err := bcrypt.GenerateFromPassword(
|
||||
[]byte(newPassword),
|
||||
byteutil.S2B(newPassword),
|
||||
bcrypt.DefaultCost,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
|
@ -22,6 +22,7 @@ import (
|
|||
"net/http"
|
||||
"testing"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
@ -37,7 +38,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordOK() {
|
|||
errWithCode := suite.user.PasswordChange(context.Background(), user, "password", "verygoodnewpassword")
|
||||
suite.NoError(errWithCode)
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(user.EncryptedPassword), []byte("verygoodnewpassword"))
|
||||
err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.EncryptedPassword),
|
||||
byteutil.S2B("verygoodnewpassword"),
|
||||
)
|
||||
suite.NoError(err)
|
||||
|
||||
// get user from the db again
|
||||
|
@ -46,7 +50,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordOK() {
|
|||
suite.NoError(err)
|
||||
|
||||
// check the password has changed
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("verygoodnewpassword"))
|
||||
err = bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(dbUser.EncryptedPassword),
|
||||
byteutil.S2B("verygoodnewpassword"),
|
||||
)
|
||||
suite.NoError(err)
|
||||
}
|
||||
|
||||
|
@ -64,7 +71,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordIncorrectOld() {
|
|||
suite.NoError(err)
|
||||
|
||||
// check the password has not changed
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password"))
|
||||
err = bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(dbUser.EncryptedPassword),
|
||||
byteutil.S2B("password"),
|
||||
)
|
||||
suite.NoError(err)
|
||||
}
|
||||
|
||||
|
@ -82,7 +92,10 @@ func (suite *ChangePasswordTestSuite) TestChangePasswordWeakNew() {
|
|||
suite.NoError(err)
|
||||
|
||||
// check the password has not changed
|
||||
err = bcrypt.CompareHashAndPassword([]byte(dbUser.EncryptedPassword), []byte("password"))
|
||||
err = bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(dbUser.EncryptedPassword),
|
||||
byteutil.S2B("password"),
|
||||
)
|
||||
suite.NoError(err)
|
||||
}
|
||||
|
||||
|
|
278
internal/processing/user/twofactor.go
Normal file
278
internal/processing/user/twofactor.go
Normal file
|
@ -0,0 +1,278 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/gruf/go-byteutil"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// EncodeQuery is a copy-paste of url.Values.Encode, except it uses
|
||||
// %20 instead of + to encode spaces. This is necessary to correctly
|
||||
// render spaces in some authenticator apps, like Google Authenticator.
|
||||
//
|
||||
// [Note: this func and the above comment are both taken
|
||||
// directly from github.com/pquerna/otp/internal/encode.go.]
|
||||
func encodeQuery(v url.Values) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
var buf strings.Builder
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
vs := v[k]
|
||||
// Changed from url.QueryEscape.
|
||||
keyEscaped := url.PathEscape(k)
|
||||
for _, v := range vs {
|
||||
if buf.Len() > 0 {
|
||||
buf.WriteByte('&')
|
||||
}
|
||||
buf.WriteString(keyEscaped)
|
||||
buf.WriteByte('=')
|
||||
// Changed from url.QueryEscape.
|
||||
buf.WriteString(url.PathEscape(v))
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// totpURLForUser reconstructs a TOTP URL for the
|
||||
// given user, setting the instance host as issuer.
|
||||
//
|
||||
// See https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
||||
func totpURLForUser(user *gtsmodel.User) *url.URL {
|
||||
issuer := config.GetHost() + " - GoToSocial"
|
||||
v := url.Values{}
|
||||
v.Set("secret", user.TwoFactorSecret)
|
||||
v.Set("issuer", issuer)
|
||||
v.Set("period", "30") // 30 seconds totp validity.
|
||||
v.Set("algorithm", "SHA1")
|
||||
v.Set("digits", "6") // 6-digit totp.
|
||||
|
||||
return &url.URL{
|
||||
Scheme: "otpauth",
|
||||
Host: "totp",
|
||||
Path: "/" + issuer + ":" + user.Email,
|
||||
RawQuery: encodeQuery(v),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Processor) TwoFactorQRCodePngGet(
|
||||
ctx context.Context,
|
||||
user *gtsmodel.User,
|
||||
) (*apimodel.Content, gtserror.WithCode) {
|
||||
// Get the 2FA url for this user.
|
||||
totpURI, errWithCode := p.TwoFactorQRCodeURIGet(ctx, user)
|
||||
if errWithCode != nil {
|
||||
return nil, errWithCode
|
||||
}
|
||||
|
||||
key, err := otp.NewKeyFromURL(totpURI.String())
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error creating totp key from url: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Spawn a QR code image from the key.
|
||||
qr, err := key.Image(256, 256)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error creating qr image from key: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Blat the key into a buffer.
|
||||
buf := new(bytes.Buffer)
|
||||
if err := png.Encode(buf, qr); err != nil {
|
||||
err := gtserror.Newf("error encoding qr image to png: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Return it as our nice content model.
|
||||
return &apimodel.Content{
|
||||
ContentType: "image/png",
|
||||
ContentLength: int64(buf.Len()),
|
||||
Content: io.NopCloser(buf),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Processor) TwoFactorQRCodeURIGet(
|
||||
ctx context.Context,
|
||||
user *gtsmodel.User,
|
||||
) (*url.URL, gtserror.WithCode) {
|
||||
// Check if we need to lazily
|
||||
// generate a new 2fa secret.
|
||||
if user.TwoFactorSecret == "" {
|
||||
// We do! Read some random crap.
|
||||
// 32 bytes should be plenty entropy.
|
||||
secret := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, secret); err != nil {
|
||||
err := gtserror.Newf("error generating new secret: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
// Set + store the secret.
|
||||
user.TwoFactorSecret = b32NoPadding.EncodeToString(secret)
|
||||
if err := p.state.DB.UpdateUser(ctx, user, "two_factor_secret"); err != nil {
|
||||
err := gtserror.Newf("db error updating user: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
} else if user.TwoFactorEnabled() {
|
||||
// If a secret is already set, and 2fa is
|
||||
// already enabled, we shouldn't share the
|
||||
// secret via QR code again: Someone may
|
||||
// have obtained a token for this user and
|
||||
// is trying to get the 2fa secret so they
|
||||
// can escalate an attack or something.
|
||||
const errText = "2fa already enabled; keeping the secret secret"
|
||||
return nil, gtserror.NewErrorConflict(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
// Recreate the totp key.
|
||||
return totpURLForUser(user), nil
|
||||
}
|
||||
|
||||
func (p *Processor) TwoFactorEnable(
|
||||
ctx context.Context,
|
||||
user *gtsmodel.User,
|
||||
code string,
|
||||
) ([]string, gtserror.WithCode) {
|
||||
if user.TwoFactorSecret == "" {
|
||||
// User doesn't have a secret set, which
|
||||
// means they never got the QR code to scan
|
||||
// into their authenticator app. We can safely
|
||||
// return an error from this request.
|
||||
const errText = "no 2fa secret stored yet; read the qr code first"
|
||||
return nil, gtserror.NewErrorForbidden(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
if user.TwoFactorEnabled() {
|
||||
const errText = "2fa already enabled; disable it first then try again"
|
||||
return nil, gtserror.NewErrorConflict(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
// Try validating the provided code and give
|
||||
// a helpful error message if it doesn't work.
|
||||
if !totp.Validate(code, user.TwoFactorSecret) {
|
||||
const errText = "invalid code provided, you may have been too late, try again; " +
|
||||
"if it keeps not working, pester your admin to check that the server clock is correct"
|
||||
return nil, gtserror.NewErrorForbidden(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
// Valid code was provided so we
|
||||
// should turn 2fa on for this user.
|
||||
user.TwoFactorEnabledAt = time.Now()
|
||||
|
||||
// Create recovery codes in cleartext
|
||||
// to show to the user ONCE ONLY.
|
||||
backupsClearText := make([]string, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
backupsClearText[i] = util.MustGenerateSecret()
|
||||
}
|
||||
|
||||
// Store only the bcrypt-encrypted
|
||||
// versions of the recovery codes.
|
||||
user.TwoFactorBackups = make([]string, 8)
|
||||
for i, backup := range backupsClearText {
|
||||
encryptedBackup, err := bcrypt.GenerateFromPassword(
|
||||
byteutil.S2B(backup),
|
||||
bcrypt.DefaultCost,
|
||||
)
|
||||
if err != nil {
|
||||
err := gtserror.Newf("error encrypting backup codes: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
user.TwoFactorBackups[i] = string(encryptedBackup)
|
||||
}
|
||||
|
||||
if err := p.state.DB.UpdateUser(
|
||||
ctx,
|
||||
user,
|
||||
"two_factor_enabled_at",
|
||||
"two_factor_backups",
|
||||
); err != nil {
|
||||
err := gtserror.Newf("db error updating user: %w", err)
|
||||
return nil, gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return backupsClearText, nil
|
||||
}
|
||||
|
||||
func (p *Processor) TwoFactorDisable(
|
||||
ctx context.Context,
|
||||
user *gtsmodel.User,
|
||||
password string,
|
||||
) gtserror.WithCode {
|
||||
if !user.TwoFactorEnabled() {
|
||||
const errText = "2fa already disabled"
|
||||
return gtserror.NewErrorConflict(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
// Ensure provided password is correct.
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
byteutil.S2B(user.EncryptedPassword),
|
||||
byteutil.S2B(password),
|
||||
); err != nil {
|
||||
const errText = "incorrect password"
|
||||
return gtserror.NewErrorUnauthorized(errors.New(errText), errText)
|
||||
}
|
||||
|
||||
// Disable 2fa for this user
|
||||
// and clear backup codes.
|
||||
user.TwoFactorEnabledAt = time.Time{}
|
||||
user.TwoFactorSecret = ""
|
||||
user.TwoFactorBackups = nil
|
||||
if err := p.state.DB.UpdateUser(
|
||||
ctx,
|
||||
user,
|
||||
"two_factor_enabled_at",
|
||||
"two_factor_secret",
|
||||
"two_factor_backups",
|
||||
); err != nil {
|
||||
err := gtserror.Newf("db error updating user: %w", err)
|
||||
return gtserror.NewErrorInternalError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -99,6 +99,10 @@ func (c *Converter) UserToAPIUser(ctx context.Context, u *gtsmodel.User) *apimod
|
|||
user.ResetPasswordSentAt = util.FormatISO8601(u.ResetPasswordSentAt)
|
||||
}
|
||||
|
||||
if !u.TwoFactorEnabledAt.IsZero() {
|
||||
user.TwoFactorEnabledAt = util.FormatISO8601(u.TwoFactorEnabledAt)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
|
|
47
internal/util/secret.go
Normal file
47
internal/util/secret.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"io"
|
||||
)
|
||||
|
||||
// crockfordBase32 is an encoding alphabet that misses characters I,L,O,U,
|
||||
// to avoid confusion and abuse. See: http://www.crockford.com/wrmg/base32.html
|
||||
const crockfordBase32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
// base32enc is a pre-initialized CrockfordBase32 encoding without any padding.
|
||||
var base32enc = base32.NewEncoding(crockfordBase32).WithPadding(base32.NoPadding)
|
||||
|
||||
// MustGenerateSecret returns a cryptographically-secure,
|
||||
// CrockfordBase32-encoded string of 32 chars in length
|
||||
// (ie., 20-bytes/160 bits of entropy), or panics on error.
|
||||
//
|
||||
// The source of randomness is crypto/rand.
|
||||
func MustGenerateSecret() string {
|
||||
// Crockford base32 with no padding
|
||||
// encodes 20 bytes to 32 characters.
|
||||
const blen = 20
|
||||
b := make([]byte, blen)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return base32enc.EncodeToString(b)
|
||||
}
|
40
internal/util/secret_test.go
Normal file
40
internal/util/secret_test.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
func TestMustGenerateSecret(t *testing.T) {
|
||||
var (
|
||||
rStr = `^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{32}$`
|
||||
r = regexp.MustCompile(rStr)
|
||||
)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
secret := util.MustGenerateSecret()
|
||||
if !r.MatchString(secret) {
|
||||
t.Logf("%d: secret %s does not match regex %s", i, secret, rStr)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
}
|
1
vendor/github.com/boombuler/barcode/.gitignore
generated
vendored
Normal file
1
vendor/github.com/boombuler/barcode/.gitignore
generated
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.vscode/
|
21
vendor/github.com/boombuler/barcode/LICENSE
generated
vendored
Normal file
21
vendor/github.com/boombuler/barcode/LICENSE
generated
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Florian Sundermann
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
53
vendor/github.com/boombuler/barcode/README.md
generated
vendored
Normal file
53
vendor/github.com/boombuler/barcode/README.md
generated
vendored
Normal file
|
@ -0,0 +1,53 @@
|
|||
[](https://gitter.im/golang-barcode/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
|
||||
## Introduction ##
|
||||
|
||||
This is a package for GO which can be used to create different types of barcodes.
|
||||
|
||||
## Supported Barcode Types ##
|
||||
* 2 of 5
|
||||
* Aztec Code
|
||||
* Codabar
|
||||
* Code 128
|
||||
* Code 39
|
||||
* Code 93
|
||||
* Datamatrix
|
||||
* EAN 13
|
||||
* EAN 8
|
||||
* PDF 417
|
||||
* QR Code
|
||||
|
||||
## Example ##
|
||||
|
||||
This is a simple example on how to create a QR-Code and write it to a png-file
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"image/png"
|
||||
"os"
|
||||
|
||||
"github.com/boombuler/barcode"
|
||||
"github.com/boombuler/barcode/qr"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create the barcode
|
||||
qrCode, _ := qr.Encode("Hello World", qr.M, qr.Auto)
|
||||
|
||||
// Scale the barcode to 200x200 pixels
|
||||
qrCode, _ = barcode.Scale(qrCode, 200, 200)
|
||||
|
||||
// create the output file
|
||||
file, _ := os.Create("qrcode.png")
|
||||
defer file.Close()
|
||||
|
||||
// encode the barcode as png
|
||||
png.Encode(file, qrCode)
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation ##
|
||||
See [GoDoc](https://godoc.org/github.com/boombuler/barcode)
|
||||
|
||||
To create a barcode use the Encode function from one of the subpackages.
|
42
vendor/github.com/boombuler/barcode/barcode.go
generated
vendored
Normal file
42
vendor/github.com/boombuler/barcode/barcode.go
generated
vendored
Normal file
|
@ -0,0 +1,42 @@
|
|||
package barcode
|
||||
|
||||
import "image"
|
||||
|
||||
const (
|
||||
TypeAztec = "Aztec"
|
||||
TypeCodabar = "Codabar"
|
||||
TypeCode128 = "Code 128"
|
||||
TypeCode39 = "Code 39"
|
||||
TypeCode93 = "Code 93"
|
||||
TypeDataMatrix = "DataMatrix"
|
||||
TypeEAN8 = "EAN 8"
|
||||
TypeEAN13 = "EAN 13"
|
||||
TypePDF = "PDF417"
|
||||
TypeQR = "QR Code"
|
||||
Type2of5 = "2 of 5"
|
||||
Type2of5Interleaved = "2 of 5 (interleaved)"
|
||||
)
|
||||
|
||||
// Contains some meta information about a barcode
|
||||
type Metadata struct {
|
||||
// the name of the barcode kind
|
||||
CodeKind string
|
||||
// contains 1 for 1D barcodes or 2 for 2D barcodes
|
||||
Dimensions byte
|
||||
}
|
||||
|
||||
// a rendered and encoded barcode
|
||||
type Barcode interface {
|
||||
image.Image
|
||||
// returns some meta information about the barcode
|
||||
Metadata() Metadata
|
||||
// the data that was encoded in this barcode
|
||||
Content() string
|
||||
}
|
||||
|
||||
// Additional interface that some barcodes might implement to provide
|
||||
// the value of its checksum.
|
||||
type BarcodeIntCS interface {
|
||||
Barcode
|
||||
CheckSum() int
|
||||
}
|
66
vendor/github.com/boombuler/barcode/qr/alphanumeric.go
generated
vendored
Normal file
66
vendor/github.com/boombuler/barcode/qr/alphanumeric.go
generated
vendored
Normal file
|
@ -0,0 +1,66 @@
|
|||
package qr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/boombuler/barcode/utils"
|
||||
)
|
||||
|
||||
const charSet string = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
|
||||
|
||||
func stringToAlphaIdx(content string) <-chan int {
|
||||
result := make(chan int)
|
||||
go func() {
|
||||
for _, r := range content {
|
||||
idx := strings.IndexRune(charSet, r)
|
||||
result <- idx
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
close(result)
|
||||
}()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func encodeAlphaNumeric(content string, ecl ErrorCorrectionLevel) (*utils.BitList, *versionInfo, error) {
|
||||
|
||||
contentLenIsOdd := len(content)%2 == 1
|
||||
contentBitCount := (len(content) / 2) * 11
|
||||
if contentLenIsOdd {
|
||||
contentBitCount += 6
|
||||
}
|
||||
vi := findSmallestVersionInfo(ecl, alphaNumericMode, contentBitCount)
|
||||
if vi == nil {
|
||||
return nil, nil, errors.New("To much data to encode")
|
||||
}
|
||||
|
||||
res := new(utils.BitList)
|
||||
res.AddBits(int(alphaNumericMode), 4)
|
||||
res.AddBits(len(content), vi.charCountBits(alphaNumericMode))
|
||||
|
||||
encoder := stringToAlphaIdx(content)
|
||||
|
||||
for idx := 0; idx < len(content)/2; idx++ {
|
||||
c1 := <-encoder
|
||||
c2 := <-encoder
|
||||
if c1 < 0 || c2 < 0 {
|
||||
return nil, nil, fmt.Errorf("\"%s\" can not be encoded as %s", content, AlphaNumeric)
|
||||
}
|
||||
res.AddBits(c1*45+c2, 11)
|
||||
}
|
||||
if contentLenIsOdd {
|
||||
c := <-encoder
|
||||
if c < 0 {
|
||||
return nil, nil, fmt.Errorf("\"%s\" can not be encoded as %s", content, AlphaNumeric)
|
||||
}
|
||||
res.AddBits(c, 6)
|
||||
}
|
||||
|
||||
addPaddingAndTerminator(res, vi)
|
||||
|
||||
return res, vi, nil
|
||||
}
|
23
vendor/github.com/boombuler/barcode/qr/automatic.go
generated
vendored
Normal file
23
vendor/github.com/boombuler/barcode/qr/automatic.go
generated
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
package qr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/boombuler/barcode/utils"
|
||||
)
|
||||
|
||||
func encodeAuto(content string, ecl ErrorCorrectionLevel) (*utils.BitList, *versionInfo, error) {
|
||||
bits, vi, _ := Numeric.getEncoder()(content, ecl)
|
||||
if bits != nil && vi != nil {
|
||||
return bits, vi, nil
|
||||
}
|
||||
bits, vi, _ = AlphaNumeric.getEncoder()(content, ecl)
|
||||
if bits != nil && vi != nil {
|
||||
return bits, vi, nil
|
||||
}
|
||||
bits, vi, _ = Unicode.getEncoder()(content, ecl)
|
||||
if bits != nil && vi != nil {
|
||||
return bits, vi, nil
|
||||
}
|
||||
return nil, nil, fmt.Errorf("No encoding found to encode \"%s\"", content)
|
||||
}
|
59
vendor/github.com/boombuler/barcode/qr/blocks.go
generated
vendored
Normal file
59
vendor/github.com/boombuler/barcode/qr/blocks.go
generated
vendored
Normal file
|
@ -0,0 +1,59 @@
|
|||
package qr
|
||||
|
||||
type block struct {
|
||||
data []byte
|
||||
ecc []byte
|
||||
}
|
||||
type blockList []*block
|
||||
|
||||
func splitToBlocks(data <-chan byte, vi *versionInfo) blockList {
|
||||
result := make(blockList, vi.NumberOfBlocksInGroup1+vi.NumberOfBlocksInGroup2)
|
||||
|
||||
for b := 0; b < int(vi.NumberOfBlocksInGroup1); b++ {
|
||||
blk := new(block)
|
||||
blk.data = make([]byte, vi.DataCodeWordsPerBlockInGroup1)
|
||||
for cw := 0; cw < int(vi.DataCodeWordsPerBlockInGroup1); cw++ {
|
||||
blk.data[cw] = <-data
|
||||
}
|
||||
blk.ecc = ec.calcECC(blk.data, vi.ErrorCorrectionCodewordsPerBlock)
|
||||
result[b] = blk
|
||||
}
|
||||
|
||||
for b := 0; b < int(vi.NumberOfBlocksInGroup2); b++ {
|
||||
blk := new(block)
|
||||
blk.data = make([]byte, vi.DataCodeWordsPerBlockInGroup2)
|
||||
for cw := 0; cw < int(vi.DataCodeWordsPerBlockInGroup2); cw++ {
|
||||
blk.data[cw] = <-data
|
||||
}
|
||||
blk.ecc = ec.calcECC(blk.data, vi.ErrorCorrectionCodewordsPerBlock)
|
||||
result[int(vi.NumberOfBlocksInGroup1)+b] = blk
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (bl blockList) interleave(vi *versionInfo) []byte {
|
||||
var maxCodewordCount int
|
||||
if vi.DataCodeWordsPerBlockInGroup1 > vi.DataCodeWordsPerBlockInGroup2 {
|
||||
maxCodewordCount = int(vi.DataCodeWordsPerBlockInGroup1)
|
||||
} else {
|
||||
maxCodewordCount = int(vi.DataCodeWordsPerBlockInGroup2)
|
||||
}
|
||||
resultLen := (vi.DataCodeWordsPerBlockInGroup1+vi.ErrorCorrectionCodewordsPerBlock)*vi.NumberOfBlocksInGroup1 +
|
||||
(vi.DataCodeWordsPerBlockInGroup2+vi.ErrorCorrectionCodewordsPerBlock)*vi.NumberOfBlocksInGroup2
|
||||
|
||||
result := make([]byte, 0, resultLen)
|
||||
for i := 0; i < maxCodewordCount; i++ {
|
||||
for b := 0; b < len(bl); b++ {
|
||||
if len(bl[b].data) > i {
|
||||
result = append(result, bl[b].data[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := 0; i < int(vi.ErrorCorrectionCodewordsPerBlock); i++ {
|
||||
for b := 0; b < len(bl); b++ {
|
||||
result = append(result, bl[b].ecc[i])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
416
vendor/github.com/boombuler/barcode/qr/encoder.go
generated
vendored
Normal file
416
vendor/github.com/boombuler/barcode/qr/encoder.go
generated
vendored
Normal file
|
@ -0,0 +1,416 @@
|
|||
// Package qr can be used to create QR barcodes.
|
||||
package qr
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"github.com/boombuler/barcode"
|
||||
"github.com/boombuler/barcode/utils"
|
||||
)
|
||||
|
||||
type encodeFn func(content string, eccLevel ErrorCorrectionLevel) (*utils.BitList, *versionInfo, error)
|
||||
|
||||
// Encoding mode for QR Codes.
|
||||
type Encoding byte
|
||||
|
||||
const (
|
||||
// Auto will choose ths best matching encoding
|
||||
Auto Encoding = iota
|
||||
// Numeric encoding only encodes numbers [0-9]
|
||||
Numeric
|
||||
// AlphaNumeric encoding only encodes uppercase letters, numbers and [Space], $, %, *, +, -, ., /, :
|
||||
AlphaNumeric
|
||||
// Unicode encoding encodes the string as utf-8
|
||||
Unicode
|
||||
// only for testing purpose
|
||||
unknownEncoding
|
||||
)
|
||||
|
||||
func (e Encoding) getEncoder() encodeFn {
|
||||
switch e {
|
||||
case Auto:
|
||||
return encodeAuto
|
||||
case Numeric:
|
||||
return encodeNumeric
|
||||
case AlphaNumeric:
|
||||
return encodeAlphaNumeric
|
||||
case Unicode:
|
||||
return encodeUnicode
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e Encoding) String() string {
|
||||
switch e {
|
||||
case Auto:
|
||||
return "Auto"
|
||||
case Numeric:
|
||||
return "Numeric"
|
||||
case AlphaNumeric:
|
||||
return "AlphaNumeric"
|
||||
case Unicode:
|
||||
return "Unicode"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Encode returns a QR barcode with the given content, error correction level and uses the given encoding
|
||||
func Encode(content string, level ErrorCorrectionLevel, mode Encoding) (barcode.Barcode, error) {
|
||||
bits, vi, err := mode.getEncoder()(content, level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blocks := splitToBlocks(bits.IterateBytes(), vi)
|
||||
data := blocks.interleave(vi)
|
||||
result := render(data, vi)
|
||||
result.content = content
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func render(data []byte, vi *versionInfo) *qrcode {
|
||||
dim := vi.modulWidth()
|
||||
results := make([]*qrcode, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
results[i] = newBarcode(dim)
|
||||
}
|
||||
|
||||
occupied := newBarcode(dim)
|
||||
|
||||
setAll := func(x int, y int, val bool) {
|
||||
occupied.Set(x, y, true)
|
||||
for i := 0; i < 8; i++ {
|
||||
results[i].Set(x, y, val)
|
||||
}
|
||||
}
|
||||
|
||||
drawFinderPatterns(vi, setAll)
|
||||
drawAlignmentPatterns(occupied, vi, setAll)
|
||||
|
||||
//Timing Pattern:
|
||||
var i int
|
||||
for i = 0; i < dim; i++ {
|
||||
if !occupied.Get(i, 6) {
|
||||
setAll(i, 6, i%2 == 0)
|
||||
}
|
||||
if !occupied.Get(6, i) {
|
||||
setAll(6, i, i%2 == 0)
|
||||
}
|
||||
}
|
||||
// Dark Module
|
||||
setAll(8, dim-8, true)
|
||||
|
||||
drawVersionInfo(vi, setAll)
|
||||
drawFormatInfo(vi, -1, occupied.Set)
|
||||
for i := 0; i < 8; i++ {
|
||||
drawFormatInfo(vi, i, results[i].Set)
|
||||
}
|
||||
|
||||
// Write the data
|
||||
var curBitNo int
|
||||
|
||||
for pos := range iterateModules(occupied) {
|
||||
var curBit bool
|
||||
if curBitNo < len(data)*8 {
|
||||
curBit = ((data[curBitNo/8] >> uint(7-(curBitNo%8))) & 1) == 1
|
||||
} else {
|
||||
curBit = false
|
||||
}
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
setMasked(pos.X, pos.Y, curBit, i, results[i].Set)
|
||||
}
|
||||
curBitNo++
|
||||
}
|
||||
|
||||
lowestPenalty := ^uint(0)
|
||||
lowestPenaltyIdx := -1
|
||||
for i := 0; i < 8; i++ {
|
||||
p := results[i].calcPenalty()
|
||||
if p < lowestPenalty {
|
||||
lowestPenalty = p
|
||||
lowestPenaltyIdx = i
|
||||
}
|
||||
}
|
||||
return results[lowestPenaltyIdx]
|
||||
}
|
||||
|
||||
func setMasked(x, y int, val bool, mask int, set func(int, int, bool)) {
|
||||
switch mask {
|
||||
case 0:
|
||||
val = val != (((y + x) % 2) == 0)
|
||||
break
|
||||
case 1:
|
||||
val = val != ((y % 2) == 0)
|
||||
break
|
||||
case 2:
|
||||
val = val != ((x % 3) == 0)
|
||||
break
|
||||
case 3:
|
||||
val = val != (((y + x) % 3) == 0)
|
||||
break
|
||||
case 4:
|
||||
val = val != (((y/2 + x/3) % 2) == 0)
|
||||
break
|
||||
case 5:
|
||||
val = val != (((y*x)%2)+((y*x)%3) == 0)
|
||||
break
|
||||
case 6:
|
||||
val = val != ((((y*x)%2)+((y*x)%3))%2 == 0)
|
||||
break
|
||||
case 7:
|
||||
val = val != ((((y+x)%2)+((y*x)%3))%2 == 0)
|
||||
}
|
||||
set(x, y, val)
|
||||
}
|
||||
|
||||
func iterateModules(occupied *qrcode) <-chan image.Point {
|
||||
result := make(chan image.Point)
|
||||
allPoints := make(chan image.Point)
|
||||
go func() {
|
||||
curX := occupied.dimension - 1
|
||||
curY := occupied.dimension - 1
|
||||
isUpward := true
|
||||
|
||||
for true {
|
||||
if isUpward {
|
||||
allPoints <- image.Pt(curX, curY)
|
||||
allPoints <- image.Pt(curX-1, curY)
|
||||
curY--
|
||||
if curY < 0 {
|
||||
curY = 0
|
||||
curX -= 2
|
||||
if curX == 6 {
|
||||
curX--
|
||||
}
|
||||
if curX < 0 {
|
||||
break
|
||||
}
|
||||
isUpward = false
|
||||
}
|
||||
} else {
|
||||
allPoints <- image.Pt(curX, curY)
|
||||
allPoints <- image.Pt(curX-1, curY)
|
||||
curY++
|
||||
if curY >= occupied.dimension {
|
||||
curY = occupied.dimension - 1
|
||||
curX -= 2
|
||||
if curX == 6 {
|
||||
curX--
|
||||
}
|
||||
isUpward = true
|
||||
if curX < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(allPoints)
|
||||
}()
|
||||
go func() {
|
||||
for pt := range allPoints {
|
||||
if !occupied.Get(pt.X, pt.Y) {
|
||||
result <- pt
|
||||
}
|
||||
}
|
||||
close(result)
|
||||
}()
|
||||
return result
|
||||
}
|
||||
|
||||
func drawFinderPatterns(vi *versionInfo, set func(int, int, bool)) {
|
||||
dim := vi.modulWidth()
|
||||
drawPattern := func(xoff int, yoff int) {
|
||||
for x := -1; x < 8; x++ {
|
||||
for y := -1; y < 8; y++ {
|
||||
val := (x == 0 || x == 6 || y == 0 || y == 6 || (x > 1 && x < 5 && y > 1 && y < 5)) && (x <= 6 && y <= 6 && x >= 0 && y >= 0)
|
||||
|
||||
if x+xoff >= 0 && x+xoff < dim && y+yoff >= 0 && y+yoff < dim {
|
||||
set(x+xoff, y+yoff, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drawPattern(0, 0)
|
||||
drawPattern(0, dim-7)
|
||||
drawPattern(dim-7, 0)
|
||||
}
|
||||
|
||||
func drawAlignmentPatterns(occupied *qrcode, vi *versionInfo, set func(int, int, bool)) {
|
||||
drawPattern := func(xoff int, yoff int) {
|
||||
for x := -2; x <= 2; x++ {
|
||||
for y := -2; y <= 2; y++ {
|
||||
val := x == -2 || x == 2 || y == -2 || y == 2 || (x == 0 && y == 0)
|
||||
set(x+xoff, y+yoff, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
positions := vi.alignmentPatternPlacements()
|
||||
|
||||
for _, x := range positions {
|
||||
for _, y := range positions {
|
||||
if occupied.Get(x, y) {
|
||||
continue
|
||||
}
|
||||
drawPattern(x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var formatInfos = map[ErrorCorrectionLevel]map[int][]bool{
|
||||
L: {
|
||||
0: []bool{true, true, true, false, true, true, true, true, true, false, false, false, true, false, false},
|
||||
1: []bool{true, true, true, false, false, true, false, true, true, true, true, false, false, true, true},
|
||||
2: []bool{true, true, true, true, true, false, true, true, false, true, false, true, false, true, false},
|
||||
3: []bool{true, true, true, true, false, false, false, true, false, false, true, true, true, false, true},
|
||||
4: []bool{true, true, false, false, true, true, false, false, false, true, false, true, true, true, true},
|
||||
5: []bool{true, true, false, false, false, true, true, false, false, false, true, true, false, false, false},
|
||||
6: []bool{true, true, false, true, true, false, false, false, true, false, false, false, false, false, true},
|
||||
7: []bool{true, true, false, true, false, false, true, false, true, true, true, false, true, true, false},
|
||||
},
|
||||
M: {
|
||||
0: []bool{true, false, true, false, true, false, false, false, false, false, true, false, false, true, false},
|
||||
1: []bool{true, false, true, false, false, false, true, false, false, true, false, false, true, false, true},
|
||||
2: []bool{true, false, true, true, true, true, false, false, true, true, true, true, true, false, false},
|
||||
3: []bool{true, false, true, true, false, true, true, false, true, false, false, true, false, true, true},
|
||||
4: []bool{true, false, false, false, true, false, true, true, true, true, true, true, false, false, true},
|
||||
5: []bool{true, false, false, false, false, false, false, true, true, false, false, true, true, true, false},
|
||||
6: []bool{true, false, false, true, true, true, true, true, false, false, true, false, true, true, true},
|
||||
7: []bool{true, false, false, true, false, true, false, true, false, true, false, false, false, false, false},
|
||||
},
|
||||
Q: {
|
||||
0: []bool{false, true, true, false, true, false, true, false, true, false, true, true, true, true, true},
|
||||
1: []bool{false, true, true, false, false, false, false, false, true, true, false, true, false, false, false},
|
||||
2: []bool{false, true, true, true, true, true, true, false, false, true, true, false, false, false, true},
|
||||
3: []bool{false, true, true, true, false, true, false, false, false, false, false, false, true, true, false},
|
||||
4: []bool{false, true, false, false, true, false, false, true, false, true, true, false, true, false, false},
|
||||
5: []bool{false, true, false, false, false, false, true, true, false, false, false, false, false, true, true},
|
||||
6: []bool{false, true, false, true, true, true, false, true, true, false, true, true, false, true, false},
|
||||
7: []bool{false, true, false, true, false, true, true, true, true, true, false, true, true, false, true},
|
||||
},
|
||||
H: {
|
||||
0: []bool{false, false, true, false, true, true, false, true, false, false, false, true, false, false, true},
|
||||
1: []bool{false, false, true, false, false, true, true, true, false, true, true, true, true, true, false},
|
||||
2: []bool{false, false, true, true, true, false, false, true, true, true, false, false, true, true, true},
|
||||
3: []bool{false, false, true, true, false, false, true, true, true, false, true, false, false, false, false},
|
||||
4: []bool{false, false, false, false, true, true, true, false, true, true, false, false, false, true, false},
|
||||
5: []bool{false, false, false, false, false, true, false, false, true, false, true, false, true, false, true},
|
||||
6: []bool{false, false, false, true, true, false, true, false, false, false, false, true, true, false, false},
|
||||
7: []bool{false, false, false, true, false, false, false, false, false, true, true, true, false, true, true},
|
||||
},
|
||||
}
|
||||
|
||||
func drawFormatInfo(vi *versionInfo, usedMask int, set func(int, int, bool)) {
|
||||
var formatInfo []bool
|
||||
|
||||
if usedMask == -1 {
|
||||
formatInfo = []bool{true, true, true, true, true, true, true, true, true, true, true, true, true, true, true} // Set all to true cause -1 --> occupied mask.
|
||||
} else {
|
||||
formatInfo = formatInfos[vi.Level][usedMask]
|
||||
}
|
||||
|
||||
if len(formatInfo) == 15 {
|
||||
dim := vi.modulWidth()
|
||||
set(0, 8, formatInfo[0])
|
||||
set(1, 8, formatInfo[1])
|
||||
set(2, 8, formatInfo[2])
|
||||
set(3, 8, formatInfo[3])
|
||||
set(4, 8, formatInfo[4])
|
||||
set(5, 8, formatInfo[5])
|
||||
set(7, 8, formatInfo[6])
|
||||
set(8, 8, formatInfo[7])
|
||||
set(8, 7, formatInfo[8])
|
||||
set(8, 5, formatInfo[9])
|
||||
set(8, 4, formatInfo[10])
|
||||
set(8, 3, formatInfo[11])
|
||||
set(8, 2, formatInfo[12])
|
||||
set(8, 1, formatInfo[13])
|
||||
set(8, 0, formatInfo[14])
|
||||
|
||||
set(8, dim-1, formatInfo[0])
|
||||
set(8, dim-2, formatInfo[1])
|
||||
set(8, dim-3, formatInfo[2])
|
||||
set(8, dim-4, formatInfo[3])
|
||||
set(8, dim-5, formatInfo[4])
|
||||
set(8, dim-6, formatInfo[5])
|
||||
set(8, dim-7, formatInfo[6])
|
||||
set(dim-8, 8, formatInfo[7])
|
||||
set(dim-7, 8, formatInfo[8])
|
||||
set(dim-6, 8, formatInfo[9])
|
||||
set(dim-5, 8, formatInfo[10])
|
||||
set(dim-4, 8, formatInfo[11])
|
||||
set(dim-3, 8, formatInfo[12])
|
||||
set(dim-2, 8, formatInfo[13])
|
||||
set(dim-1, 8, formatInfo[14])
|
||||
}
|
||||
}
|
||||
|
||||
var versionInfoBitsByVersion = map[byte][]bool{
|
||||
7: []bool{false, false, false, true, true, true, true, true, false, false, true, false, false, true, false, true, false, false},
|
||||
8: []bool{false, false, true, false, false, false, false, true, false, true, true, false, true, true, true, true, false, false},
|
||||
9: []bool{false, false, true, false, false, true, true, false, true, false, true, false, false, true, true, false, false, true},
|
||||
10: []bool{false, false, true, false, true, false, false, true, false, false, true, true, false, true, false, false, true, true},
|
||||
11: []bool{false, false, true, false, true, true, true, false, true, true, true, true, true, true, false, true, true, false},
|
||||
12: []bool{false, false, true, true, false, false, false, true, true, true, false, true, true, false, false, false, true, false},
|
||||
13: []bool{false, false, true, true, false, true, true, false, false, false, false, true, false, false, false, true, true, true},
|
||||
14: []bool{false, false, true, true, true, false, false, true, true, false, false, false, false, false, true, true, false, true},
|
||||
15: []bool{false, false, true, true, true, true, true, false, false, true, false, false, true, false, true, false, false, false},
|
||||
16: []bool{false, true, false, false, false, false, true, false, true, true, false, true, true, true, true, false, false, false},
|
||||
17: []bool{false, true, false, false, false, true, false, true, false, false, false, true, false, true, true, true, false, true},
|
||||
18: []bool{false, true, false, false, true, false, true, false, true, false, false, false, false, true, false, true, true, true},
|
||||
19: []bool{false, true, false, false, true, true, false, true, false, true, false, false, true, true, false, false, true, false},
|
||||
20: []bool{false, true, false, true, false, false, true, false, false, true, true, false, true, false, false, true, true, false},
|
||||
21: []bool{false, true, false, true, false, true, false, true, true, false, true, false, false, false, false, false, true, true},
|
||||
22: []bool{false, true, false, true, true, false, true, false, false, false, true, true, false, false, true, false, false, true},
|
||||
23: []bool{false, true, false, true, true, true, false, true, true, true, true, true, true, false, true, true, false, false},
|
||||
24: []bool{false, true, true, false, false, false, true, true, true, false, true, true, false, false, false, true, false, false},
|
||||
25: []bool{false, true, true, false, false, true, false, false, false, true, true, true, true, false, false, false, false, true},
|
||||
26: []bool{false, true, true, false, true, false, true, true, true, true, true, false, true, false, true, false, true, true},
|
||||
27: []bool{false, true, true, false, true, true, false, false, false, false, true, false, false, false, true, true, true, false},
|
||||
28: []bool{false, true, true, true, false, false, true, true, false, false, false, false, false, true, true, false, true, false},
|
||||
29: []bool{false, true, true, true, false, true, false, false, true, true, false, false, true, true, true, true, true, true},
|
||||
30: []bool{false, true, true, true, true, false, true, true, false, true, false, true, true, true, false, true, false, true},
|
||||
31: []bool{false, true, true, true, true, true, false, false, true, false, false, true, false, true, false, false, false, false},
|
||||
32: []bool{true, false, false, false, false, false, true, false, false, true, true, true, false, true, false, true, false, true},
|
||||
33: []bool{true, false, false, false, false, true, false, true, true, false, true, true, true, true, false, false, false, false},
|
||||
34: []bool{true, false, false, false, true, false, true, false, false, false, true, false, true, true, true, false, true, false},
|
||||
35: []bool{true, false, false, false, true, true, false, true, true, true, true, false, false, true, true, true, true, true},
|
||||
36: []bool{true, false, false, true, false, false, true, false, true, true, false, false, false, false, true, false, true, true},
|
||||
37: []bool{true, false, false, true, false, true, false, true, false, false, false, false, true, false, true, true, true, false},
|
||||
38: []bool{true, false, false, true, true, false, true, false, true, false, false, true, true, false, false, true, false, false},
|
||||
39: []bool{true, false, false, true, true, true, false, true, false, true, false, true, false, false, false, false, false, true},
|
||||
40: []bool{true, false, true, false, false, false, true, true, false, false, false, true, true, false, true, false, false, true},
|
||||
}
|
||||
|
||||
func drawVersionInfo(vi *versionInfo, set func(int, int, bool)) {
|
||||
versionInfoBits, ok := versionInfoBitsByVersion[vi.Version]
|
||||
|
||||
if ok && len(versionInfoBits) > 0 {
|
||||
for i := 0; i < len(versionInfoBits); i++ {
|
||||
x := (vi.modulWidth() - 11) + i%3
|
||||
y := i / 3
|
||||
set(x, y, versionInfoBits[len(versionInfoBits)-i-1])
|
||||
set(y, x, versionInfoBits[len(versionInfoBits)-i-1])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func addPaddingAndTerminator(bl *utils.BitList, vi *versionInfo) {
|
||||
for i := 0; i < 4 && bl.Len() < vi.totalDataBytes()*8; i++ {
|
||||
bl.AddBit(false)
|
||||
}
|
||||
|
||||
for bl.Len()%8 != 0 {
|
||||
bl.AddBit(false)
|
||||
}
|
||||
|
||||
for i := 0; bl.Len() < vi.totalDataBytes()*8; i++ {
|
||||
if i%2 == 0 {
|
||||
bl.AddByte(236)
|
||||
} else {
|
||||
bl.AddByte(17)
|
||||
}
|
||||
}
|
||||
}
|
29
vendor/github.com/boombuler/barcode/qr/errorcorrection.go
generated
vendored
Normal file
29
vendor/github.com/boombuler/barcode/qr/errorcorrection.go
generated
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
package qr
|
||||
|
||||
import (
|
||||
"github.com/boombuler/barcode/utils"
|
||||
)
|
||||
|
||||
type errorCorrection struct {
|
||||
rs *utils.ReedSolomonEncoder
|
||||
}
|
||||
|
||||
var ec = newErrorCorrection()
|
||||
|
||||
func newErrorCorrection() *errorCorrection {
|
||||
fld := utils.NewGaloisField(285, 256, 0)
|
||||
return &errorCorrection{utils.NewReedSolomonEncoder(fld)}
|
||||
}
|
||||
|
||||
func (ec *errorCorrection) calcECC(data []byte, eccCount byte) []byte {
|
||||
dataInts := make([]int, len(data))
|
||||
for i := 0; i < len(data); i++ {
|
||||
dataInts[i] = int(data[i])
|
||||
}
|
||||
res := ec.rs.Encode(dataInts, int(eccCount))
|
||||
result := make([]byte, len(res))
|
||||
for i := 0; i < len(res); i++ {
|
||||
result[i] = byte(res[i])
|
||||
}
|
||||
return result
|
||||
}
|
56
vendor/github.com/boombuler/barcode/qr/numeric.go
generated
vendored
Normal file
56
vendor/github.com/boombuler/barcode/qr/numeric.go
generated
vendored
Normal file
|
@ -0,0 +1,56 @@
|
|||
package qr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/boombuler/barcode/utils"
|
||||
)
|
||||
|
||||
func encodeNumeric(content string, ecl ErrorCorrectionLevel) (*utils.BitList, *versionInfo, error) {
|
||||
contentBitCount := (len(content) / 3) * 10
|
||||
switch len(content) % 3 {
|
||||
case 1:
|
||||
contentBitCount += 4
|
||||
case 2:
|
||||
contentBitCount += 7
|
||||
}
|
||||
vi := findSmallestVersionInfo(ecl, numericMode, contentBitCount)
|
||||
if vi == nil {
|
||||
return nil, nil, errors.New("To much data to encode")
|
||||
}
|
||||
res := new(utils.BitList)
|
||||
res.AddBits(int(numericMode), 4)
|
||||
res.AddBits(len(content), vi.charCountBits(numericMode))
|
||||
|
||||
for pos := 0; pos < len(content); pos += 3 {
|
||||
var curStr string
|
||||
if pos+3 <= len(content) {
|
||||
curStr = content[pos : pos+3]
|
||||
} else {
|
||||
curStr = content[pos:]
|
||||
}
|
||||
|
||||
i, err := strconv.Atoi(curStr)
|
||||
if err != nil || i < 0 {
|
||||
return nil, nil, fmt.Errorf("\"%s\" can not be encoded as %s", content, Numeric)
|
||||
}
|
||||
var bitCnt byte
|
||||
switch len(curStr) % 3 {
|
||||
case 0:
|
||||
bitCnt = 10
|
||||
case 1:
|
||||
bitCnt = 4
|
||||
break
|
||||
case 2:
|
||||
bitCnt = 7
|
||||
break
|
||||
}
|
||||
|
||||
res.AddBits(i, bitCnt)
|
||||
}
|
||||
|
||||
addPaddingAndTerminator(res, vi)
|
||||
return res, vi, nil
|
||||
}
|
166
vendor/github.com/boombuler/barcode/qr/qrcode.go
generated
vendored
Normal file
166
vendor/github.com/boombuler/barcode/qr/qrcode.go
generated
vendored
Normal file
|
@ -0,0 +1,166 @@
|
|||
package qr
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"github.com/boombuler/barcode"
|
||||
"github.com/boombuler/barcode/utils"
|
||||
)
|
||||
|
||||
type qrcode struct {
|
||||
dimension int
|
||||
data *utils.BitList
|
||||
content string
|
||||
}
|
||||
|
||||
func (qr *qrcode) Content() string {
|
||||
return qr.content
|
||||
}
|
||||
|
||||
func (qr *qrcode) Metadata() barcode.Metadata {
|
||||
return barcode.Metadata{barcode.TypeQR, 2}
|
||||
}
|
||||
|
||||
func (qr *qrcode) ColorModel() color.Model {
|
||||
return color.Gray16Model
|
||||
}
|
||||
|
||||
func (qr *qrcode) Bounds() image.Rectangle {
|
||||
return image.Rect(0, 0, qr.dimension, qr.dimension)
|
||||
}
|
||||
|
||||
func (qr *qrcode) At(x, y int) color.Color {
|
||||
if qr.Get(x, y) {
|
||||
return color.Black
|
||||
}
|
||||
return color.White
|
||||
}
|
||||
|
||||
func (qr *qrcode) Get(x, y int) bool {
|
||||
return qr.data.GetBit(x*qr.dimension + y)
|
||||
}
|
||||
|
||||
func (qr *qrcode) Set(x, y int, val bool) {
|
||||
qr.data.SetBit(x*qr.dimension+y, val)
|
||||
}
|
||||
|
||||
func (qr *qrcode) calcPenalty() uint {
|
||||
return qr.calcPenaltyRule1() + qr.calcPenaltyRule2() + qr.calcPenaltyRule3() + qr.calcPenaltyRule4()
|
||||
}
|
||||
|
||||
func (qr *qrcode) calcPenaltyRule1() uint {
|
||||
var result uint
|
||||
for x := 0; x < qr.dimension; x++ {
|
||||
checkForX := false
|
||||
var cntX uint
|
||||
checkForY := false
|
||||
var cntY uint
|
||||
|
||||
for y := 0; y < qr.dimension; y++ {
|
||||
if qr.Get(x, y) == checkForX {
|
||||
cntX++
|
||||
} else {
|
||||
checkForX = !checkForX
|
||||
if cntX >= 5 {
|
||||
result += cntX - 2
|
||||
}
|
||||
cntX = 1
|
||||
}
|
||||
|
||||
if qr.Get(y, x) == checkForY {
|
||||
cntY++
|
||||
} else {
|
||||
checkForY = !checkForY
|
||||
if cntY >= 5 {
|
||||
result += cntY - 2
|
||||
}
|
||||
cntY = 1
|
||||
}
|
||||
}
|
||||
|
||||
if cntX >= 5 {
|
||||
result += cntX - 2
|
||||
}
|
||||
if cntY >= 5 {
|
||||
result += cntY - 2
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (qr *qrcode) calcPenaltyRule2() uint {
|
||||
var result uint
|
||||
for x := 0; x < qr.dimension-1; x++ {
|
||||
for y := 0; y < qr.dimension-1; y++ {
|
||||
check := qr.Get(x, y)
|
||||
if qr.Get(x, y+1) == check && qr.Get(x+1, y) == check && qr.Get(x+1, y+1) == check {
|
||||
result += 3
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (qr *qrcode) calcPenaltyRule3() uint {
|
||||
pattern1 := []bool{true, false, true, true, true, false, true, false, false, false, false}
|
||||
pattern2 := []bool{false, false, false, false, true, false, true, true, true, false, true}
|
||||
|
||||
var result uint
|
||||
for x := 0; x <= qr.dimension-len(pattern1); x++ {
|
||||
for y := 0; y < qr.dimension; y++ {
|
||||
pattern1XFound := true
|
||||
pattern2XFound := true
|
||||
pattern1YFound := true
|
||||
pattern2YFound := true
|
||||
|
||||
for i := 0; i < len(pattern1); i++ {
|
||||
iv := qr.Get(x+i, y)
|
||||
if iv != pattern1[i] {
|
||||
pattern1XFound = false
|
||||
}
|
||||
if iv != pattern2[i] {
|
||||
pattern2XFound = false
|
||||
}
|
||||
iv = qr.Get(y, x+i)
|
||||
if iv != pattern1[i] {
|
||||
pattern1YFound = false
|
||||
}
|
||||
if iv != pattern2[i] {
|
||||
pattern2YFound = false
|
||||
}
|
||||
}
|
||||
if pattern1XFound || pattern2XFound {
|
||||
result += 40
|
||||
}
|
||||
if pattern1YFound || pattern2YFound {
|
||||
result += 40
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (qr *qrcode) calcPenaltyRule4() uint {
|
||||
totalNum := qr.data.Len()
|
||||
trueCnt := 0
|
||||
for i := 0; i < totalNum; i++ {
|
||||
if qr.data.GetBit(i) {
|
||||
trueCnt++
|
||||
}
|
||||
}
|
||||
percDark := float64(trueCnt) * 100 / float64(totalNum)
|
||||
floor := math.Abs(math.Floor(percDark/5) - 10)
|
||||
ceil := math.Abs(math.Ceil(percDark/5) - 10)
|
||||
return uint(math.Min(floor, ceil) * 10)
|
||||
}
|
||||
|
||||
func newBarcode(dim int) *qrcode {
|
||||
res := new(qrcode)
|
||||
res.dimension = dim
|
||||
res.data = utils.NewBitList(dim * dim)
|
||||
return res
|
||||
}
|
27
vendor/github.com/boombuler/barcode/qr/unicode.go
generated
vendored
Normal file
27
vendor/github.com/boombuler/barcode/qr/unicode.go
generated
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
package qr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/boombuler/barcode/utils"
|
||||
)
|
||||
|
||||
func encodeUnicode(content string, ecl ErrorCorrectionLevel) (*utils.BitList, *versionInfo, error) {
|
||||
data := []byte(content)
|
||||
|
||||
vi := findSmallestVersionInfo(ecl, byteMode, len(data)*8)
|
||||
if vi == nil {
|
||||
return nil, nil, errors.New("To much data to encode")
|
||||
}
|
||||
|
||||
// It's not correct to add the unicode bytes to the result directly but most readers can't handle the
|
||||
// required ECI header...
|
||||
res := new(utils.BitList)
|
||||
res.AddBits(int(byteMode), 4)
|
||||
res.AddBits(len(content), vi.charCountBits(byteMode))
|
||||
for _, b := range data {
|
||||
res.AddByte(b)
|
||||
}
|
||||
addPaddingAndTerminator(res, vi)
|
||||
return res, vi, nil
|
||||
}
|
310
vendor/github.com/boombuler/barcode/qr/versioninfo.go
generated
vendored
Normal file
310
vendor/github.com/boombuler/barcode/qr/versioninfo.go
generated
vendored
Normal file
|
@ -0,0 +1,310 @@
|
|||
package qr
|
||||
|
||||
import "math"
|
||||
|
||||
// ErrorCorrectionLevel indicates the amount of "backup data" stored in the QR code
|
||||
type ErrorCorrectionLevel byte
|
||||
|
||||
const (
|
||||
// L recovers 7% of data
|
||||
L ErrorCorrectionLevel = iota
|
||||
// M recovers 15% of data
|
||||
M
|
||||
// Q recovers 25% of data
|
||||
Q
|
||||
// H recovers 30% of data
|
||||
H
|
||||
)
|
||||
|
||||
func (ecl ErrorCorrectionLevel) String() string {
|
||||
switch ecl {
|
||||
case L:
|
||||
return "L"
|
||||
case M:
|
||||
return "M"
|
||||
case Q:
|
||||
return "Q"
|
||||
case H:
|
||||
return "H"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
type encodingMode byte
|
||||
|
||||
const (
|
||||
numericMode encodingMode = 1
|
||||
alphaNumericMode encodingMode = 2
|
||||
byteMode encodingMode = 4
|
||||
kanjiMode encodingMode = 8
|
||||
)
|
||||
|
||||
type versionInfo struct {
|
||||
Version byte
|
||||
Level ErrorCorrectionLevel
|
||||
ErrorCorrectionCodewordsPerBlock byte
|
||||
NumberOfBlocksInGroup1 byte
|
||||
DataCodeWordsPerBlockInGroup1 byte
|
||||
NumberOfBlocksInGroup2 byte
|
||||
DataCodeWordsPerBlockInGroup2 byte
|
||||
}
|
||||
|
||||
var versionInfos = []*versionInfo{
|
||||
&versionInfo{1, L, 7, 1, 19, 0, 0},
|
||||
&versionInfo{1, M, 10, 1, 16, 0, 0},
|
||||
&versionInfo{1, Q, 13, 1, 13, 0, 0},
|
||||
&versionInfo{1, H, 17, 1, 9, 0, 0},
|
||||
&versionInfo{2, L, 10, 1, 34, 0, 0},
|
||||
&versionInfo{2, M, 16, 1, 28, 0, 0},
|
||||
&versionInfo{2, Q, 22, 1, 22, 0, 0},
|
||||
&versionInfo{2, H, 28, 1, 16, 0, 0},
|
||||
&versionInfo{3, L, 15, 1, 55, 0, 0},
|
||||
&versionInfo{3, M, 26, 1, 44, 0, 0},
|
||||
&versionInfo{3, Q, 18, 2, 17, 0, 0},
|
||||
&versionInfo{3, H, 22, 2, 13, 0, 0},
|
||||
&versionInfo{4, L, 20, 1, 80, 0, 0},
|
||||
&versionInfo{4, M, 18, 2, 32, 0, 0},
|
||||
&versionInfo{4, Q, 26, 2, 24, 0, 0},
|
||||
&versionInfo{4, H, 16, 4, 9, 0, 0},
|
||||
&versionInfo{5, L, 26, 1, 108, 0, 0},
|
||||
&versionInfo{5, M, 24, 2, 43, 0, 0},
|
||||
&versionInfo{5, Q, 18, 2, 15, 2, 16},
|
||||
&versionInfo{5, H, 22, 2, 11, 2, 12},
|
||||
&versionInfo{6, L, 18, 2, 68, 0, 0},
|
||||
&versionInfo{6, M, 16, 4, 27, 0, 0},
|
||||
&versionInfo{6, Q, 24, 4, 19, 0, 0},
|
||||
&versionInfo{6, H, 28, 4, 15, 0, 0},
|
||||
&versionInfo{7, L, 20, 2, 78, 0, 0},
|
||||
&versionInfo{7, M, 18, 4, 31, 0, 0},
|
||||
&versionInfo{7, Q, 18, 2, 14, 4, 15},
|
||||
&versionInfo{7, H, 26, 4, 13, 1, 14},
|
||||
&versionInfo{8, L, 24, 2, 97, 0, 0},
|
||||
&versionInfo{8, M, 22, 2, 38, 2, 39},
|
||||
&versionInfo{8, Q, 22, 4, 18, 2, 19},
|
||||
&versionInfo{8, H, 26, 4, 14, 2, 15},
|
||||
&versionInfo{9, L, 30, 2, 116, 0, 0},
|
||||
&versionInfo{9, M, 22, 3, 36, 2, 37},
|
||||
&versionInfo{9, Q, 20, 4, 16, 4, 17},
|
||||
&versionInfo{9, H, 24, 4, 12, 4, 13},
|
||||
&versionInfo{10, L, 18, 2, 68, 2, 69},
|
||||
&versionInfo{10, M, 26, 4, 43, 1, 44},
|
||||
&versionInfo{10, Q, 24, 6, 19, 2, 20},
|
||||
&versionInfo{10, H, 28, 6, 15, 2, 16},
|
||||
&versionInfo{11, L, 20, 4, 81, 0, 0},
|
||||
&versionInfo{11, M, 30, 1, 50, 4, 51},
|
||||
&versionInfo{11, Q, 28, 4, 22, 4, 23},
|
||||
&versionInfo{11, H, 24, 3, 12, 8, 13},
|
||||
&versionInfo{12, L, 24, 2, 92, 2, 93},
|
||||
&versionInfo{12, M, 22, 6, 36, 2, 37},
|
||||
&versionInfo{12, Q, 26, 4, 20, 6, 21},
|
||||
&versionInfo{12, H, 28, 7, 14, 4, 15},
|
||||
&versionInfo{13, L, 26, 4, 107, 0, 0},
|
||||
&versionInfo{13, M, 22, 8, 37, 1, 38},
|
||||
&versionInfo{13, Q, 24, 8, 20, 4, 21},
|
||||
&versionInfo{13, H, 22, 12, 11, 4, 12},
|
||||
&versionInfo{14, L, 30, 3, 115, 1, 116},
|
||||
&versionInfo{14, M, 24, 4, 40, 5, 41},
|
||||
&versionInfo{14, Q, 20, 11, 16, 5, 17},
|
||||
&versionInfo{14, H, 24, 11, 12, 5, 13},
|
||||
&versionInfo{15, L, 22, 5, 87, 1, 88},
|
||||
&versionInfo{15, M, 24, 5, 41, 5, 42},
|
||||
&versionInfo{15, Q, 30, 5, 24, 7, 25},
|
||||
&versionInfo{15, H, 24, 11, 12, 7, 13},
|
||||
&versionInfo{16, L, 24, 5, 98, 1, 99},
|
||||
&versionInfo{16, M, 28, 7, 45, 3, 46},
|
||||
&versionInfo{16, Q, 24, 15, 19, 2, 20},
|
||||
&versionInfo{16, H, 30, 3, 15, 13, 16},
|
||||
&versionInfo{17, L, 28, 1, 107, 5, 108},
|
||||
&versionInfo{17, M, 28, 10, 46, 1, 47},
|
||||
&versionInfo{17, Q, 28, 1, 22, 15, 23},
|
||||
&versionInfo{17, H, 28, 2, 14, 17, 15},
|
||||
&versionInfo{18, L, 30, 5, 120, 1, 121},
|
||||
&versionInfo{18, M, 26, 9, 43, 4, 44},
|
||||
&versionInfo{18, Q, 28, 17, 22, 1, 23},
|
||||
&versionInfo{18, H, 28, 2, 14, 19, 15},
|
||||
&versionInfo{19, L, 28, 3, 113, 4, 114},
|
||||
&versionInfo{19, M, 26, 3, 44, 11, 45},
|
||||
&versionInfo{19, Q, 26, 17, 21, 4, 22},
|
||||
&versionInfo{19, H, 26, 9, 13, 16, 14},
|
||||
&versionInfo{20, L, 28, 3, 107, 5, 108},
|
||||
&versionInfo{20, M, 26, 3, 41, 13, 42},
|
||||
&versionInfo{20, Q, 30, 15, 24, 5, 25},
|
||||
&versionInfo{20, H, 28, 15, 15, 10, 16},
|
||||
&versionInfo{21, L, 28, 4, 116, 4, 117},
|
||||
&versionInfo{21, M, 26, 17, 42, 0, 0},
|
||||
&versionInfo{21, Q, 28, 17, 22, 6, 23},
|
||||
&versionInfo{21, H, 30, 19, 16, 6, 17},
|
||||
&versionInfo{22, L, 28, 2, 111, 7, 112},
|
||||
&versionInfo{22, M, 28, 17, 46, 0, 0},
|
||||
&versionInfo{22, Q, 30, 7, 24, 16, 25},
|
||||
&versionInfo{22, H, 24, 34, 13, 0, 0},
|
||||
&versionInfo{23, L, 30, 4, 121, 5, 122},
|
||||
&versionInfo{23, M, 28, 4, 47, 14, 48},
|
||||
&versionInfo{23, Q, 30, 11, 24, 14, 25},
|
||||
&versionInfo{23, H, 30, 16, 15, 14, 16},
|
||||
&versionInfo{24, L, 30, 6, 117, 4, 118},
|
||||
&versionInfo{24, M, 28, 6, 45, 14, 46},
|
||||
&versionInfo{24, Q, 30, 11, 24, 16, 25},
|
||||
&versionInfo{24, H, 30, 30, 16, 2, 17},
|
||||
&versionInfo{25, L, 26, 8, 106, 4, 107},
|
||||
&versionInfo{25, M, 28, 8, 47, 13, 48},
|
||||
&versionInfo{25, Q, 30, 7, 24, 22, 25},
|
||||
&versionInfo{25, H, 30, 22, 15, 13, 16},
|
||||
&versionInfo{26, L, 28, 10, 114, 2, 115},
|
||||
&versionInfo{26, M, 28, 19, 46, 4, 47},
|
||||
&versionInfo{26, Q, 28, 28, 22, 6, 23},
|
||||
&versionInfo{26, H, 30, 33, 16, 4, 17},
|
||||
&versionInfo{27, L, 30, 8, 122, 4, 123},
|
||||
&versionInfo{27, M, 28, 22, 45, 3, 46},
|
||||
&versionInfo{27, Q, 30, 8, 23, 26, 24},
|
||||
&versionInfo{27, H, 30, 12, 15, 28, 16},
|
||||
&versionInfo{28, L, 30, 3, 117, 10, 118},
|
||||
&versionInfo{28, M, 28, 3, 45, 23, 46},
|
||||
&versionInfo{28, Q, 30, 4, 24, 31, 25},
|
||||
&versionInfo{28, H, 30, 11, 15, 31, 16},
|
||||
&versionInfo{29, L, 30, 7, 116, 7, 117},
|
||||
&versionInfo{29, M, 28, 21, 45, 7, 46},
|
||||
&versionInfo{29, Q, 30, 1, 23, 37, 24},
|
||||
&versionInfo{29, H, 30, 19, 15, 26, 16},
|
||||
&versionInfo{30, L, 30, 5, 115, 10, 116},
|
||||
&versionInfo{30, M, 28, 19, 47, 10, 48},
|
||||
&versionInfo{30, Q, 30, 15, 24, 25, 25},
|
||||
&versionInfo{30, H, 30, 23, 15, 25, 16},
|
||||
&versionInfo{31, L, 30, 13, 115, 3, 116},
|
||||
&versionInfo{31, M, 28, 2, 46, 29, 47},
|
||||
&versionInfo{31, Q, 30, 42, 24, 1, 25},
|
||||
&versionInfo{31, H, 30, 23, 15, 28, 16},
|
||||
&versionInfo{32, L, 30, 17, 115, 0, 0},
|
||||
&versionInfo{32, M, 28, 10, 46, 23, 47},
|
||||
&versionInfo{32, Q, 30, 10, 24, 35, 25},
|
||||
&versionInfo{32, H, 30, 19, 15, 35, 16},
|
||||
&versionInfo{33, L, 30, 17, 115, 1, 116},
|
||||
&versionInfo{33, M, 28, 14, 46, 21, 47},
|
||||
&versionInfo{33, Q, 30, 29, 24, 19, 25},
|
||||
&versionInfo{33, H, 30, 11, 15, 46, 16},
|
||||
&versionInfo{34, L, 30, 13, 115, 6, 116},
|
||||
&versionInfo{34, M, 28, 14, 46, 23, 47},
|
||||
&versionInfo{34, Q, 30, 44, 24, 7, 25},
|
||||
&versionInfo{34, H, 30, 59, 16, 1, 17},
|
||||
&versionInfo{35, L, 30, 12, 121, 7, 122},
|
||||
&versionInfo{35, M, 28, 12, 47, 26, 48},
|
||||
&versionInfo{35, Q, 30, 39, 24, 14, 25},
|
||||
&versionInfo{35, H, 30, 22, 15, 41, 16},
|
||||
&versionInfo{36, L, 30, 6, 121, 14, 122},
|
||||
&versionInfo{36, M, 28, 6, 47, 34, 48},
|
||||
&versionInfo{36, Q, 30, 46, 24, 10, 25},
|
||||
&versionInfo{36, H, 30, 2, 15, 64, 16},
|
||||
&versionInfo{37, L, 30, 17, 122, 4, 123},
|
||||
&versionInfo{37, M, 28, 29, 46, 14, 47},
|
||||
&versionInfo{37, Q, 30, 49, 24, 10, 25},
|
||||
&versionInfo{37, H, 30, 24, 15, 46, 16},
|
||||
&versionInfo{38, L, 30, 4, 122, 18, 123},
|
||||
&versionInfo{38, M, 28, 13, 46, 32, 47},
|
||||
&versionInfo{38, Q, 30, 48, 24, 14, 25},
|
||||
&versionInfo{38, H, 30, 42, 15, 32, 16},
|
||||
&versionInfo{39, L, 30, 20, 117, 4, 118},
|
||||
&versionInfo{39, M, 28, 40, 47, 7, 48},
|
||||
&versionInfo{39, Q, 30, 43, 24, 22, 25},
|
||||
&versionInfo{39, H, 30, 10, 15, 67, 16},
|
||||
&versionInfo{40, L, 30, 19, 118, 6, 119},
|
||||
&versionInfo{40, M, 28, 18, 47, 31, 48},
|
||||
&versionInfo{40, Q, 30, 34, 24, 34, 25},
|
||||
&versionInfo{40, H, 30, 20, 15, 61, 16},
|
||||
}
|
||||
|
||||
func (vi *versionInfo) totalDataBytes() int {
|
||||
g1Data := int(vi.NumberOfBlocksInGroup1) * int(vi.DataCodeWordsPerBlockInGroup1)
|
||||
g2Data := int(vi.NumberOfBlocksInGroup2) * int(vi.DataCodeWordsPerBlockInGroup2)
|
||||
return (g1Data + g2Data)
|
||||
}
|
||||
|
||||
func (vi *versionInfo) charCountBits(m encodingMode) byte {
|
||||
switch m {
|
||||
case numericMode:
|
||||
if vi.Version < 10 {
|
||||
return 10
|
||||
} else if vi.Version < 27 {
|
||||
return 12
|
||||
}
|
||||
return 14
|
||||
|
||||
case alphaNumericMode:
|
||||
if vi.Version < 10 {
|
||||
return 9
|
||||
} else if vi.Version < 27 {
|
||||
return 11
|
||||
}
|
||||
return 13
|
||||
|
||||
case byteMode:
|
||||
if vi.Version < 10 {
|
||||
return 8
|
||||
}
|
||||
return 16
|
||||
|
||||
case kanjiMode:
|
||||
if vi.Version < 10 {
|
||||
return 8
|
||||
} else if vi.Version < 27 {
|
||||
return 10
|
||||
}
|
||||
return 12
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (vi *versionInfo) modulWidth() int {
|
||||
return ((int(vi.Version) - 1) * 4) + 21
|
||||
}
|
||||
|
||||
func (vi *versionInfo) alignmentPatternPlacements() []int {
|
||||
if vi.Version == 1 {
|
||||
return make([]int, 0)
|
||||
}
|
||||
|
||||
first := 6
|
||||
last := vi.modulWidth() - 7
|
||||
space := float64(last - first)
|
||||
count := int(math.Ceil(space/28)) + 1
|
||||
|
||||
result := make([]int, count)
|
||||
result[0] = first
|
||||
result[len(result)-1] = last
|
||||
if count > 2 {
|
||||
step := int(math.Ceil(float64(last-first) / float64(count-1)))
|
||||
if step%2 == 1 {
|
||||
frac := float64(last-first) / float64(count-1)
|
||||
_, x := math.Modf(frac)
|
||||
if x >= 0.5 {
|
||||
frac = math.Ceil(frac)
|
||||
} else {
|
||||
frac = math.Floor(frac)
|
||||
}
|
||||
|
||||
if int(frac)%2 == 0 {
|
||||
step--
|
||||
} else {
|
||||
step++
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i <= count-2; i++ {
|
||||
result[i] = last - (step * (count - 1 - i))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func findSmallestVersionInfo(ecl ErrorCorrectionLevel, mode encodingMode, dataBits int) *versionInfo {
|
||||
dataBits = dataBits + 4 // mode indicator
|
||||
for _, vi := range versionInfos {
|
||||
if vi.Level == ecl {
|
||||
if (vi.totalDataBytes() * 8) >= (dataBits + int(vi.charCountBits(mode))) {
|
||||
return vi
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
134
vendor/github.com/boombuler/barcode/scaledbarcode.go
generated
vendored
Normal file
134
vendor/github.com/boombuler/barcode/scaledbarcode.go
generated
vendored
Normal file
|
@ -0,0 +1,134 @@
|
|||
package barcode
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
)
|
||||
|
||||
type wrapFunc func(x, y int) color.Color
|
||||
|
||||
type scaledBarcode struct {
|
||||
wrapped Barcode
|
||||
wrapperFunc wrapFunc
|
||||
rect image.Rectangle
|
||||
}
|
||||
|
||||
type intCSscaledBC struct {
|
||||
scaledBarcode
|
||||
}
|
||||
|
||||
func (bc *scaledBarcode) Content() string {
|
||||
return bc.wrapped.Content()
|
||||
}
|
||||
|
||||
func (bc *scaledBarcode) Metadata() Metadata {
|
||||
return bc.wrapped.Metadata()
|
||||
}
|
||||
|
||||
func (bc *scaledBarcode) ColorModel() color.Model {
|
||||
return bc.wrapped.ColorModel()
|
||||
}
|
||||
|
||||
func (bc *scaledBarcode) Bounds() image.Rectangle {
|
||||
return bc.rect
|
||||
}
|
||||
|
||||
func (bc *scaledBarcode) At(x, y int) color.Color {
|
||||
return bc.wrapperFunc(x, y)
|
||||
}
|
||||
|
||||
func (bc *intCSscaledBC) CheckSum() int {
|
||||
if cs, ok := bc.wrapped.(BarcodeIntCS); ok {
|
||||
return cs.CheckSum()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Scale returns a resized barcode with the given width and height.
|
||||
func Scale(bc Barcode, width, height int) (Barcode, error) {
|
||||
switch bc.Metadata().Dimensions {
|
||||
case 1:
|
||||
return scale1DCode(bc, width, height)
|
||||
case 2:
|
||||
return scale2DCode(bc, width, height)
|
||||
}
|
||||
|
||||
return nil, errors.New("unsupported barcode format")
|
||||
}
|
||||
|
||||
func newScaledBC(wrapped Barcode, wrapperFunc wrapFunc, rect image.Rectangle) Barcode {
|
||||
result := &scaledBarcode{
|
||||
wrapped: wrapped,
|
||||
wrapperFunc: wrapperFunc,
|
||||
rect: rect,
|
||||
}
|
||||
|
||||
if _, ok := wrapped.(BarcodeIntCS); ok {
|
||||
return &intCSscaledBC{*result}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func scale2DCode(bc Barcode, width, height int) (Barcode, error) {
|
||||
orgBounds := bc.Bounds()
|
||||
orgWidth := orgBounds.Max.X - orgBounds.Min.X
|
||||
orgHeight := orgBounds.Max.Y - orgBounds.Min.Y
|
||||
|
||||
factor := int(math.Min(float64(width)/float64(orgWidth), float64(height)/float64(orgHeight)))
|
||||
if factor <= 0 {
|
||||
return nil, fmt.Errorf("can not scale barcode to an image smaller than %dx%d", orgWidth, orgHeight)
|
||||
}
|
||||
|
||||
offsetX := (width - (orgWidth * factor)) / 2
|
||||
offsetY := (height - (orgHeight * factor)) / 2
|
||||
|
||||
wrap := func(x, y int) color.Color {
|
||||
if x < offsetX || y < offsetY {
|
||||
return color.White
|
||||
}
|
||||
x = (x - offsetX) / factor
|
||||
y = (y - offsetY) / factor
|
||||
if x >= orgWidth || y >= orgHeight {
|
||||
return color.White
|
||||
}
|
||||
return bc.At(x, y)
|
||||
}
|
||||
|
||||
return newScaledBC(
|
||||
bc,
|
||||
wrap,
|
||||
image.Rect(0, 0, width, height),
|
||||
), nil
|
||||
}
|
||||
|
||||
func scale1DCode(bc Barcode, width, height int) (Barcode, error) {
|
||||
orgBounds := bc.Bounds()
|
||||
orgWidth := orgBounds.Max.X - orgBounds.Min.X
|
||||
factor := int(float64(width) / float64(orgWidth))
|
||||
|
||||
if factor <= 0 {
|
||||
return nil, fmt.Errorf("can not scale barcode to an image smaller than %dx1", orgWidth)
|
||||
}
|
||||
offsetX := (width - (orgWidth * factor)) / 2
|
||||
|
||||
wrap := func(x, y int) color.Color {
|
||||
if x < offsetX {
|
||||
return color.White
|
||||
}
|
||||
x = (x - offsetX) / factor
|
||||
|
||||
if x >= orgWidth {
|
||||
return color.White
|
||||
}
|
||||
return bc.At(x, 0)
|
||||
}
|
||||
|
||||
return newScaledBC(
|
||||
bc,
|
||||
wrap,
|
||||
image.Rect(0, 0, width, height),
|
||||
), nil
|
||||
}
|
57
vendor/github.com/boombuler/barcode/utils/base1dcode.go
generated
vendored
Normal file
57
vendor/github.com/boombuler/barcode/utils/base1dcode.go
generated
vendored
Normal file
|
@ -0,0 +1,57 @@
|
|||
// Package utils contain some utilities which are needed to create barcodes
|
||||
package utils
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"github.com/boombuler/barcode"
|
||||
)
|
||||
|
||||
type base1DCode struct {
|
||||
*BitList
|
||||
kind string
|
||||
content string
|
||||
}
|
||||
|
||||
type base1DCodeIntCS struct {
|
||||
base1DCode
|
||||
checksum int
|
||||
}
|
||||
|
||||
func (c *base1DCode) Content() string {
|
||||
return c.content
|
||||
}
|
||||
|
||||
func (c *base1DCode) Metadata() barcode.Metadata {
|
||||
return barcode.Metadata{c.kind, 1}
|
||||
}
|
||||
|
||||
func (c *base1DCode) ColorModel() color.Model {
|
||||
return color.Gray16Model
|
||||
}
|
||||
|
||||
func (c *base1DCode) Bounds() image.Rectangle {
|
||||
return image.Rect(0, 0, c.Len(), 1)
|
||||
}
|
||||
|
||||
func (c *base1DCode) At(x, y int) color.Color {
|
||||
if c.GetBit(x) {
|
||||
return color.Black
|
||||
}
|
||||
return color.White
|
||||
}
|
||||
|
||||
func (c *base1DCodeIntCS) CheckSum() int {
|
||||
return c.checksum
|
||||
}
|
||||
|
||||
// New1DCodeIntCheckSum creates a new 1D barcode where the bars are represented by the bits in the bars BitList
|
||||
func New1DCodeIntCheckSum(codeKind, content string, bars *BitList, checksum int) barcode.BarcodeIntCS {
|
||||
return &base1DCodeIntCS{base1DCode{bars, codeKind, content}, checksum}
|
||||
}
|
||||
|
||||
// New1DCode creates a new 1D barcode where the bars are represented by the bits in the bars BitList
|
||||
func New1DCode(codeKind, content string, bars *BitList) barcode.Barcode {
|
||||
return &base1DCode{bars, codeKind, content}
|
||||
}
|
119
vendor/github.com/boombuler/barcode/utils/bitlist.go
generated
vendored
Normal file
119
vendor/github.com/boombuler/barcode/utils/bitlist.go
generated
vendored
Normal file
|
@ -0,0 +1,119 @@
|
|||
package utils
|
||||
|
||||
// BitList is a list that contains bits
|
||||
type BitList struct {
|
||||
count int
|
||||
data []int32
|
||||
}
|
||||
|
||||
// NewBitList returns a new BitList with the given length
|
||||
// all bits are initialize with false
|
||||
func NewBitList(capacity int) *BitList {
|
||||
bl := new(BitList)
|
||||
bl.count = capacity
|
||||
x := 0
|
||||
if capacity%32 != 0 {
|
||||
x = 1
|
||||
}
|
||||
bl.data = make([]int32, capacity/32+x)
|
||||
return bl
|
||||
}
|
||||
|
||||
// Len returns the number of contained bits
|
||||
func (bl *BitList) Len() int {
|
||||
return bl.count
|
||||
}
|
||||
|
||||
func (bl *BitList) grow() {
|
||||
growBy := len(bl.data)
|
||||
if growBy < 128 {
|
||||
growBy = 128
|
||||
} else if growBy >= 1024 {
|
||||
growBy = 1024
|
||||
}
|
||||
|
||||
nd := make([]int32, len(bl.data)+growBy)
|
||||
copy(nd, bl.data)
|
||||
bl.data = nd
|
||||
}
|
||||
|
||||
// AddBit appends the given bits to the end of the list
|
||||
func (bl *BitList) AddBit(bits ...bool) {
|
||||
for _, bit := range bits {
|
||||
itmIndex := bl.count / 32
|
||||
for itmIndex >= len(bl.data) {
|
||||
bl.grow()
|
||||
}
|
||||
bl.SetBit(bl.count, bit)
|
||||
bl.count++
|
||||
}
|
||||
}
|
||||
|
||||
// SetBit sets the bit at the given index to the given value
|
||||
func (bl *BitList) SetBit(index int, value bool) {
|
||||
itmIndex := index / 32
|
||||
itmBitShift := 31 - (index % 32)
|
||||
if value {
|
||||
bl.data[itmIndex] = bl.data[itmIndex] | 1<<uint(itmBitShift)
|
||||
} else {
|
||||
bl.data[itmIndex] = bl.data[itmIndex] & ^(1 << uint(itmBitShift))
|
||||
}
|
||||
}
|
||||
|
||||
// GetBit returns the bit at the given index
|
||||
func (bl *BitList) GetBit(index int) bool {
|
||||
itmIndex := index / 32
|
||||
itmBitShift := 31 - (index % 32)
|
||||
return ((bl.data[itmIndex] >> uint(itmBitShift)) & 1) == 1
|
||||
}
|
||||
|
||||
// AddByte appends all 8 bits of the given byte to the end of the list
|
||||
func (bl *BitList) AddByte(b byte) {
|
||||
for i := 7; i >= 0; i-- {
|
||||
bl.AddBit(((b >> uint(i)) & 1) == 1)
|
||||
}
|
||||
}
|
||||
|
||||
// AddBits appends the last (LSB) 'count' bits of 'b' the the end of the list
|
||||
func (bl *BitList) AddBits(b int, count byte) {
|
||||
for i := int(count) - 1; i >= 0; i-- {
|
||||
bl.AddBit(((b >> uint(i)) & 1) == 1)
|
||||
}
|
||||
}
|
||||
|
||||
// GetBytes returns all bits of the BitList as a []byte
|
||||
func (bl *BitList) GetBytes() []byte {
|
||||
len := bl.count >> 3
|
||||
if (bl.count % 8) != 0 {
|
||||
len++
|
||||
}
|
||||
result := make([]byte, len)
|
||||
for i := 0; i < len; i++ {
|
||||
shift := (3 - (i % 4)) * 8
|
||||
result[i] = (byte)((bl.data[i/4] >> uint(shift)) & 0xFF)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IterateBytes iterates through all bytes contained in the BitList
|
||||
func (bl *BitList) IterateBytes() <-chan byte {
|
||||
res := make(chan byte)
|
||||
|
||||
go func() {
|
||||
c := bl.count
|
||||
shift := 24
|
||||
i := 0
|
||||
for c > 0 {
|
||||
res <- byte((bl.data[i] >> uint(shift)) & 0xFF)
|
||||
shift -= 8
|
||||
if shift < 0 {
|
||||
shift = 24
|
||||
i++
|
||||
}
|
||||
c -= 8
|
||||
}
|
||||
close(res)
|
||||
}()
|
||||
|
||||
return res
|
||||
}
|
65
vendor/github.com/boombuler/barcode/utils/galoisfield.go
generated
vendored
Normal file
65
vendor/github.com/boombuler/barcode/utils/galoisfield.go
generated
vendored
Normal file
|
@ -0,0 +1,65 @@
|
|||
package utils
|
||||
|
||||
// GaloisField encapsulates galois field arithmetics
|
||||
type GaloisField struct {
|
||||
Size int
|
||||
Base int
|
||||
ALogTbl []int
|
||||
LogTbl []int
|
||||
}
|
||||
|
||||
// NewGaloisField creates a new galois field
|
||||
func NewGaloisField(pp, fieldSize, b int) *GaloisField {
|
||||
result := new(GaloisField)
|
||||
|
||||
result.Size = fieldSize
|
||||
result.Base = b
|
||||
result.ALogTbl = make([]int, fieldSize)
|
||||
result.LogTbl = make([]int, fieldSize)
|
||||
|
||||
x := 1
|
||||
for i := 0; i < fieldSize; i++ {
|
||||
result.ALogTbl[i] = x
|
||||
x = x * 2
|
||||
if x >= fieldSize {
|
||||
x = (x ^ pp) & (fieldSize - 1)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < fieldSize; i++ {
|
||||
result.LogTbl[result.ALogTbl[i]] = int(i)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (gf *GaloisField) Zero() *GFPoly {
|
||||
return NewGFPoly(gf, []int{0})
|
||||
}
|
||||
|
||||
// AddOrSub add or substract two numbers
|
||||
func (gf *GaloisField) AddOrSub(a, b int) int {
|
||||
return a ^ b
|
||||
}
|
||||
|
||||
// Multiply multiplys two numbers
|
||||
func (gf *GaloisField) Multiply(a, b int) int {
|
||||
if a == 0 || b == 0 {
|
||||
return 0
|
||||
}
|
||||
return gf.ALogTbl[(gf.LogTbl[a]+gf.LogTbl[b])%(gf.Size-1)]
|
||||
}
|
||||
|
||||
// Divide divides two numbers
|
||||
func (gf *GaloisField) Divide(a, b int) int {
|
||||
if b == 0 {
|
||||
panic("divide by zero")
|
||||
} else if a == 0 {
|
||||
return 0
|
||||
}
|
||||
return gf.ALogTbl[(gf.LogTbl[a]-gf.LogTbl[b])%(gf.Size-1)]
|
||||
}
|
||||
|
||||
func (gf *GaloisField) Invers(num int) int {
|
||||
return gf.ALogTbl[(gf.Size-1)-gf.LogTbl[num]]
|
||||
}
|
103
vendor/github.com/boombuler/barcode/utils/gfpoly.go
generated
vendored
Normal file
103
vendor/github.com/boombuler/barcode/utils/gfpoly.go
generated
vendored
Normal file
|
@ -0,0 +1,103 @@
|
|||
package utils
|
||||
|
||||
type GFPoly struct {
|
||||
gf *GaloisField
|
||||
Coefficients []int
|
||||
}
|
||||
|
||||
func (gp *GFPoly) Degree() int {
|
||||
return len(gp.Coefficients) - 1
|
||||
}
|
||||
|
||||
func (gp *GFPoly) Zero() bool {
|
||||
return gp.Coefficients[0] == 0
|
||||
}
|
||||
|
||||
// GetCoefficient returns the coefficient of x ^ degree
|
||||
func (gp *GFPoly) GetCoefficient(degree int) int {
|
||||
return gp.Coefficients[gp.Degree()-degree]
|
||||
}
|
||||
|
||||
func (gp *GFPoly) AddOrSubstract(other *GFPoly) *GFPoly {
|
||||
if gp.Zero() {
|
||||
return other
|
||||
} else if other.Zero() {
|
||||
return gp
|
||||
}
|
||||
smallCoeff := gp.Coefficients
|
||||
largeCoeff := other.Coefficients
|
||||
if len(smallCoeff) > len(largeCoeff) {
|
||||
largeCoeff, smallCoeff = smallCoeff, largeCoeff
|
||||
}
|
||||
sumDiff := make([]int, len(largeCoeff))
|
||||
lenDiff := len(largeCoeff) - len(smallCoeff)
|
||||
copy(sumDiff, largeCoeff[:lenDiff])
|
||||
for i := lenDiff; i < len(largeCoeff); i++ {
|
||||
sumDiff[i] = int(gp.gf.AddOrSub(int(smallCoeff[i-lenDiff]), int(largeCoeff[i])))
|
||||
}
|
||||
return NewGFPoly(gp.gf, sumDiff)
|
||||
}
|
||||
|
||||
func (gp *GFPoly) MultByMonominal(degree int, coeff int) *GFPoly {
|
||||
if coeff == 0 {
|
||||
return gp.gf.Zero()
|
||||
}
|
||||
size := len(gp.Coefficients)
|
||||
result := make([]int, size+degree)
|
||||
for i := 0; i < size; i++ {
|
||||
result[i] = int(gp.gf.Multiply(int(gp.Coefficients[i]), int(coeff)))
|
||||
}
|
||||
return NewGFPoly(gp.gf, result)
|
||||
}
|
||||
|
||||
func (gp *GFPoly) Multiply(other *GFPoly) *GFPoly {
|
||||
if gp.Zero() || other.Zero() {
|
||||
return gp.gf.Zero()
|
||||
}
|
||||
aCoeff := gp.Coefficients
|
||||
aLen := len(aCoeff)
|
||||
bCoeff := other.Coefficients
|
||||
bLen := len(bCoeff)
|
||||
product := make([]int, aLen+bLen-1)
|
||||
for i := 0; i < aLen; i++ {
|
||||
ac := int(aCoeff[i])
|
||||
for j := 0; j < bLen; j++ {
|
||||
bc := int(bCoeff[j])
|
||||
product[i+j] = int(gp.gf.AddOrSub(int(product[i+j]), gp.gf.Multiply(ac, bc)))
|
||||
}
|
||||
}
|
||||
return NewGFPoly(gp.gf, product)
|
||||
}
|
||||
|
||||
func (gp *GFPoly) Divide(other *GFPoly) (quotient *GFPoly, remainder *GFPoly) {
|
||||
quotient = gp.gf.Zero()
|
||||
remainder = gp
|
||||
fld := gp.gf
|
||||
denomLeadTerm := other.GetCoefficient(other.Degree())
|
||||
inversDenomLeadTerm := fld.Invers(int(denomLeadTerm))
|
||||
for remainder.Degree() >= other.Degree() && !remainder.Zero() {
|
||||
degreeDiff := remainder.Degree() - other.Degree()
|
||||
scale := int(fld.Multiply(int(remainder.GetCoefficient(remainder.Degree())), inversDenomLeadTerm))
|
||||
term := other.MultByMonominal(degreeDiff, scale)
|
||||
itQuot := NewMonominalPoly(fld, degreeDiff, scale)
|
||||
quotient = quotient.AddOrSubstract(itQuot)
|
||||
remainder = remainder.AddOrSubstract(term)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func NewMonominalPoly(field *GaloisField, degree int, coeff int) *GFPoly {
|
||||
if coeff == 0 {
|
||||
return field.Zero()
|
||||
}
|
||||
result := make([]int, degree+1)
|
||||
result[0] = coeff
|
||||
return NewGFPoly(field, result)
|
||||
}
|
||||
|
||||
func NewGFPoly(field *GaloisField, coefficients []int) *GFPoly {
|
||||
for len(coefficients) > 1 && coefficients[0] == 0 {
|
||||
coefficients = coefficients[1:]
|
||||
}
|
||||
return &GFPoly{field, coefficients}
|
||||
}
|
44
vendor/github.com/boombuler/barcode/utils/reedsolomon.go
generated
vendored
Normal file
44
vendor/github.com/boombuler/barcode/utils/reedsolomon.go
generated
vendored
Normal file
|
@ -0,0 +1,44 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ReedSolomonEncoder struct {
|
||||
gf *GaloisField
|
||||
polynomes []*GFPoly
|
||||
m *sync.Mutex
|
||||
}
|
||||
|
||||
func NewReedSolomonEncoder(gf *GaloisField) *ReedSolomonEncoder {
|
||||
return &ReedSolomonEncoder{
|
||||
gf, []*GFPoly{NewGFPoly(gf, []int{1})}, new(sync.Mutex),
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *ReedSolomonEncoder) getPolynomial(degree int) *GFPoly {
|
||||
rs.m.Lock()
|
||||
defer rs.m.Unlock()
|
||||
|
||||
if degree >= len(rs.polynomes) {
|
||||
last := rs.polynomes[len(rs.polynomes)-1]
|
||||
for d := len(rs.polynomes); d <= degree; d++ {
|
||||
next := last.Multiply(NewGFPoly(rs.gf, []int{1, rs.gf.ALogTbl[d-1+rs.gf.Base]}))
|
||||
rs.polynomes = append(rs.polynomes, next)
|
||||
last = next
|
||||
}
|
||||
}
|
||||
return rs.polynomes[degree]
|
||||
}
|
||||
|
||||
func (rs *ReedSolomonEncoder) Encode(data []int, eccCount int) []int {
|
||||
generator := rs.getPolynomial(eccCount)
|
||||
info := NewGFPoly(rs.gf, data)
|
||||
info = info.MultByMonominal(eccCount, 1)
|
||||
_, remainder := info.Divide(generator)
|
||||
|
||||
result := make([]int, eccCount)
|
||||
numZero := int(eccCount) - len(remainder.Coefficients)
|
||||
copy(result[numZero:], remainder.Coefficients)
|
||||
return result
|
||||
}
|
19
vendor/github.com/boombuler/barcode/utils/runeint.go
generated
vendored
Normal file
19
vendor/github.com/boombuler/barcode/utils/runeint.go
generated
vendored
Normal file
|
@ -0,0 +1,19 @@
|
|||
package utils
|
||||
|
||||
// RuneToInt converts a rune between '0' and '9' to an integer between 0 and 9
|
||||
// If the rune is outside of this range -1 is returned.
|
||||
func RuneToInt(r rune) int {
|
||||
if r >= '0' && r <= '9' {
|
||||
return int(r - '0')
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// IntToRune converts a digit 0 - 9 to the rune '0' - '9'. If the given int is outside
|
||||
// of this range 'F' is returned!
|
||||
func IntToRune(i int) rune {
|
||||
if i >= 0 && i <= 9 {
|
||||
return rune(i + '0')
|
||||
}
|
||||
return 'F'
|
||||
}
|
10
vendor/github.com/pquerna/otp/.travis.yml
generated
vendored
Normal file
10
vendor/github.com/pquerna/otp/.travis.yml
generated
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
arch:
|
||||
- amd64
|
||||
- ppc64le
|
||||
language: go
|
||||
|
||||
env:
|
||||
- GO111MODULE=on
|
||||
|
||||
go:
|
||||
- "1.15"
|
202
vendor/github.com/pquerna/otp/LICENSE
generated
vendored
Normal file
202
vendor/github.com/pquerna/otp/LICENSE
generated
vendored
Normal file
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
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
|
||||
|
||||
http://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.
|
5
vendor/github.com/pquerna/otp/NOTICE
generated
vendored
Normal file
5
vendor/github.com/pquerna/otp/NOTICE
generated
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
otp
|
||||
Copyright (c) 2014, Paul Querna
|
||||
|
||||
This product includes software developed by
|
||||
Paul Querna (http://paul.querna.org/).
|
60
vendor/github.com/pquerna/otp/README.md
generated
vendored
Normal file
60
vendor/github.com/pquerna/otp/README.md
generated
vendored
Normal file
|
@ -0,0 +1,60 @@
|
|||
# otp: One Time Password utilities Go / Golang
|
||||
|
||||
[](https://pkg.go.dev/github.com/pquerna/otp) [](https://travis-ci.org/pquerna/otp)
|
||||
|
||||
# Why One Time Passwords?
|
||||
|
||||
One Time Passwords (OTPs) are an mechanism to improve security over passwords alone. When a Time-based OTP (TOTP) is stored on a user's phone, and combined with something the user knows (Password), you have an easy on-ramp to [Multi-factor authentication](http://en.wikipedia.org/wiki/Multi-factor_authentication) without adding a dependency on a SMS provider. This Password and TOTP combination is used by many popular websites including Google, GitHub, Facebook, Salesforce and many others.
|
||||
|
||||
The `otp` library enables you to easily add TOTPs to your own application, increasing your user's security against mass-password breaches and malware.
|
||||
|
||||
Because TOTP is standardized and widely deployed, there are many [mobile clients and software implementations](http://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm#Client_implementations).
|
||||
|
||||
## `otp` Supports:
|
||||
|
||||
* Generating QR Code images for easy user enrollment.
|
||||
* Time-based One-time Password Algorithm (TOTP) (RFC 6238): Time based OTP, the most commonly used method.
|
||||
* HMAC-based One-time Password Algorithm (HOTP) (RFC 4226): Counter based OTP, which TOTP is based upon.
|
||||
* Generation and Validation of codes for either algorithm.
|
||||
|
||||
## Implementing TOTP in your application:
|
||||
|
||||
### User Enrollment
|
||||
|
||||
For an example of a working enrollment work flow, [GitHub has documented theirs](https://help.github.com/articles/configuring-two-factor-authentication-via-a-totp-mobile-app/
|
||||
), but the basics are:
|
||||
|
||||
1. Generate new TOTP Key for a User. `key,_ := totp.Generate(...)`.
|
||||
1. Display the Key's Secret and QR-Code for the User. `key.Secret()` and `key.Image(...)`.
|
||||
1. Test that the user can successfully use their TOTP. `totp.Validate(...)`.
|
||||
1. Store TOTP Secret for the User in your backend. `key.Secret()`
|
||||
1. Provide the user with "recovery codes". (See Recovery Codes bellow)
|
||||
|
||||
### Code Generation
|
||||
|
||||
* In either TOTP or HOTP cases, use the `GenerateCode` function and a counter or
|
||||
`time.Time` struct to generate a valid code compatible with most implementations.
|
||||
* For uncommon or custom settings, or to catch unlikely errors, use `GenerateCodeCustom`
|
||||
in either module.
|
||||
|
||||
### Validation
|
||||
|
||||
1. Prompt and validate User's password as normal.
|
||||
1. If the user has TOTP enabled, prompt for TOTP passcode.
|
||||
1. Retrieve the User's TOTP Secret from your backend.
|
||||
1. Validate the user's passcode. `totp.Validate(...)`
|
||||
|
||||
|
||||
### Recovery Codes
|
||||
|
||||
When a user loses access to their TOTP device, they would no longer have access to their account. Because TOTPs are often configured on mobile devices that can be lost, stolen or damaged, this is a common problem. For this reason many providers give their users "backup codes" or "recovery codes". These are a set of one time use codes that can be used instead of the TOTP. These can simply be randomly generated strings that you store in your backend. [Github's documentation provides an overview of the user experience](
|
||||
https://help.github.com/articles/downloading-your-two-factor-authentication-recovery-codes/).
|
||||
|
||||
|
||||
## Improvements, bugs, adding feature, etc:
|
||||
|
||||
Please [open issues in Github](https://github.com/pquerna/otp/issues) for ideas, bugs, and general thoughts. Pull requests are of course preferred :)
|
||||
|
||||
## License
|
||||
|
||||
`otp` is licensed under the [Apache License, Version 2.0](./LICENSE)
|
71
vendor/github.com/pquerna/otp/doc.go
generated
vendored
Normal file
71
vendor/github.com/pquerna/otp/doc.go
generated
vendored
Normal file
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Copyright 2014 Paul Querna
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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 otp implements both HOTP and TOTP based
|
||||
// one time passcodes in a Google Authenticator compatible manner.
|
||||
//
|
||||
// When adding a TOTP for a user, you must store the "secret" value
|
||||
// persistently. It is recommended to store the secret in an encrypted field in your
|
||||
// datastore. Due to how TOTP works, it is not possible to store a hash
|
||||
// for the secret value like you would a password.
|
||||
//
|
||||
// To enroll a user, you must first generate an OTP for them. Google
|
||||
// Authenticator supports using a QR code as an enrollment method:
|
||||
//
|
||||
// import (
|
||||
// "github.com/pquerna/otp/totp"
|
||||
//
|
||||
// "bytes"
|
||||
// "image/png"
|
||||
// )
|
||||
//
|
||||
// key, err := totp.Generate(totp.GenerateOpts{
|
||||
// Issuer: "Example.com",
|
||||
// AccountName: "alice@example.com",
|
||||
// })
|
||||
//
|
||||
// // Convert TOTP key into a QR code encoded as a PNG image.
|
||||
// var buf bytes.Buffer
|
||||
// img, err := key.Image(200, 200)
|
||||
// png.Encode(&buf, img)
|
||||
//
|
||||
// // display the QR code to the user.
|
||||
// display(buf.Bytes())
|
||||
//
|
||||
// // Now Validate that the user's successfully added the passcode.
|
||||
// passcode := promptForPasscode()
|
||||
// valid := totp.Validate(passcode, key.Secret())
|
||||
//
|
||||
// if valid {
|
||||
// // User successfully used their TOTP, save it to your backend!
|
||||
// storeSecret("alice@example.com", key.Secret())
|
||||
// }
|
||||
//
|
||||
// Validating a TOTP passcode is very easy, just prompt the user for a passcode
|
||||
// and retrieve the associated user's previously stored secret.
|
||||
//
|
||||
// import "github.com/pquerna/otp/totp"
|
||||
//
|
||||
// passcode := promptForPasscode()
|
||||
// secret := getSecret("alice@example.com")
|
||||
//
|
||||
// valid := totp.Validate(passcode, secret)
|
||||
//
|
||||
// if valid {
|
||||
// // Success! continue login process.
|
||||
// }
|
||||
package otp
|
216
vendor/github.com/pquerna/otp/hotp/hotp.go
generated
vendored
Normal file
216
vendor/github.com/pquerna/otp/hotp/hotp.go
generated
vendored
Normal file
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Copyright 2014 Paul Querna
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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 hotp
|
||||
|
||||
import (
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/internal"
|
||||
"io"
|
||||
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const debug = false
|
||||
|
||||
// Validate a HOTP passcode given a counter and secret.
|
||||
// This is a shortcut for ValidateCustom, with parameters that
|
||||
// are compataible with Google-Authenticator.
|
||||
func Validate(passcode string, counter uint64, secret string) bool {
|
||||
rv, _ := ValidateCustom(
|
||||
passcode,
|
||||
counter,
|
||||
secret,
|
||||
ValidateOpts{
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
},
|
||||
)
|
||||
return rv
|
||||
}
|
||||
|
||||
// ValidateOpts provides options for ValidateCustom().
|
||||
type ValidateOpts struct {
|
||||
// Digits as part of the input. Defaults to 6.
|
||||
Digits otp.Digits
|
||||
// Algorithm to use for HMAC. Defaults to SHA1.
|
||||
Algorithm otp.Algorithm
|
||||
}
|
||||
|
||||
// GenerateCode creates a HOTP passcode given a counter and secret.
|
||||
// This is a shortcut for GenerateCodeCustom, with parameters that
|
||||
// are compataible with Google-Authenticator.
|
||||
func GenerateCode(secret string, counter uint64) (string, error) {
|
||||
return GenerateCodeCustom(secret, counter, ValidateOpts{
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateCodeCustom uses a counter and secret value and options struct to
|
||||
// create a passcode.
|
||||
func GenerateCodeCustom(secret string, counter uint64, opts ValidateOpts) (passcode string, err error) {
|
||||
//Set default value
|
||||
if opts.Digits == 0 {
|
||||
opts.Digits = otp.DigitsSix
|
||||
}
|
||||
// As noted in issue #10 and #17 this adds support for TOTP secrets that are
|
||||
// missing their padding.
|
||||
secret = strings.TrimSpace(secret)
|
||||
if n := len(secret) % 8; n != 0 {
|
||||
secret = secret + strings.Repeat("=", 8-n)
|
||||
}
|
||||
|
||||
// As noted in issue #24 Google has started producing base32 in lower case,
|
||||
// but the StdEncoding (and the RFC), expect a dictionary of only upper case letters.
|
||||
secret = strings.ToUpper(secret)
|
||||
|
||||
secretBytes, err := base32.StdEncoding.DecodeString(secret)
|
||||
if err != nil {
|
||||
return "", otp.ErrValidateSecretInvalidBase32
|
||||
}
|
||||
|
||||
buf := make([]byte, 8)
|
||||
mac := hmac.New(opts.Algorithm.Hash, secretBytes)
|
||||
binary.BigEndian.PutUint64(buf, counter)
|
||||
if debug {
|
||||
fmt.Printf("counter=%v\n", counter)
|
||||
fmt.Printf("buf=%v\n", buf)
|
||||
}
|
||||
|
||||
mac.Write(buf)
|
||||
sum := mac.Sum(nil)
|
||||
|
||||
// "Dynamic truncation" in RFC 4226
|
||||
// http://tools.ietf.org/html/rfc4226#section-5.4
|
||||
offset := sum[len(sum)-1] & 0xf
|
||||
value := int64(((int(sum[offset]) & 0x7f) << 24) |
|
||||
((int(sum[offset+1] & 0xff)) << 16) |
|
||||
((int(sum[offset+2] & 0xff)) << 8) |
|
||||
(int(sum[offset+3]) & 0xff))
|
||||
|
||||
l := opts.Digits.Length()
|
||||
mod := int32(value % int64(math.Pow10(l)))
|
||||
|
||||
if debug {
|
||||
fmt.Printf("offset=%v\n", offset)
|
||||
fmt.Printf("value=%v\n", value)
|
||||
fmt.Printf("mod'ed=%v\n", mod)
|
||||
}
|
||||
|
||||
return opts.Digits.Format(mod), nil
|
||||
}
|
||||
|
||||
// ValidateCustom validates an HOTP with customizable options. Most users should
|
||||
// use Validate().
|
||||
func ValidateCustom(passcode string, counter uint64, secret string, opts ValidateOpts) (bool, error) {
|
||||
passcode = strings.TrimSpace(passcode)
|
||||
|
||||
if len(passcode) != opts.Digits.Length() {
|
||||
return false, otp.ErrValidateInputInvalidLength
|
||||
}
|
||||
|
||||
otpstr, err := GenerateCodeCustom(secret, counter, opts)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(otpstr), []byte(passcode)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GenerateOpts provides options for .Generate()
|
||||
type GenerateOpts struct {
|
||||
// Name of the issuing Organization/Company.
|
||||
Issuer string
|
||||
// Name of the User's Account (eg, email address)
|
||||
AccountName string
|
||||
// Size in size of the generated Secret. Defaults to 10 bytes.
|
||||
SecretSize uint
|
||||
// Secret to store. Defaults to a randomly generated secret of SecretSize. You should generally leave this empty.
|
||||
Secret []byte
|
||||
// Digits to request. Defaults to 6.
|
||||
Digits otp.Digits
|
||||
// Algorithm to use for HMAC. Defaults to SHA1.
|
||||
Algorithm otp.Algorithm
|
||||
// Reader to use for generating HOTP Key.
|
||||
Rand io.Reader
|
||||
}
|
||||
|
||||
var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// Generate creates a new HOTP Key.
|
||||
func Generate(opts GenerateOpts) (*otp.Key, error) {
|
||||
// url encode the Issuer/AccountName
|
||||
if opts.Issuer == "" {
|
||||
return nil, otp.ErrGenerateMissingIssuer
|
||||
}
|
||||
|
||||
if opts.AccountName == "" {
|
||||
return nil, otp.ErrGenerateMissingAccountName
|
||||
}
|
||||
|
||||
if opts.SecretSize == 0 {
|
||||
opts.SecretSize = 10
|
||||
}
|
||||
|
||||
if opts.Digits == 0 {
|
||||
opts.Digits = otp.DigitsSix
|
||||
}
|
||||
|
||||
if opts.Rand == nil {
|
||||
opts.Rand = rand.Reader
|
||||
}
|
||||
|
||||
// otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
|
||||
|
||||
v := url.Values{}
|
||||
if len(opts.Secret) != 0 {
|
||||
v.Set("secret", b32NoPadding.EncodeToString(opts.Secret))
|
||||
} else {
|
||||
secret := make([]byte, opts.SecretSize)
|
||||
_, err := opts.Rand.Read(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Set("secret", b32NoPadding.EncodeToString(secret))
|
||||
}
|
||||
|
||||
v.Set("issuer", opts.Issuer)
|
||||
v.Set("algorithm", opts.Algorithm.String())
|
||||
v.Set("digits", opts.Digits.String())
|
||||
|
||||
u := url.URL{
|
||||
Scheme: "otpauth",
|
||||
Host: "hotp",
|
||||
Path: "/" + opts.Issuer + ":" + opts.AccountName,
|
||||
RawQuery: internal.EncodeQuery(v),
|
||||
}
|
||||
|
||||
return otp.NewKeyFromURL(u.String())
|
||||
}
|
35
vendor/github.com/pquerna/otp/internal/encode.go
generated
vendored
Normal file
35
vendor/github.com/pquerna/otp/internal/encode.go
generated
vendored
Normal file
|
@ -0,0 +1,35 @@
|
|||
package internal
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EncodeQuery is a copy-paste of url.Values.Encode, except it uses %20 instead
|
||||
// of + to encode spaces. This is necessary to correctly render spaces in some
|
||||
// authenticator apps, like Google Authenticator.
|
||||
func EncodeQuery(v url.Values) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
var buf strings.Builder
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
vs := v[k]
|
||||
keyEscaped := url.PathEscape(k) // changed from url.QueryEscape
|
||||
for _, v := range vs {
|
||||
if buf.Len() > 0 {
|
||||
buf.WriteByte('&')
|
||||
}
|
||||
buf.WriteString(keyEscaped)
|
||||
buf.WriteByte('=')
|
||||
buf.WriteString(url.PathEscape(v)) // changed from url.QueryEscape
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
255
vendor/github.com/pquerna/otp/otp.go
generated
vendored
Normal file
255
vendor/github.com/pquerna/otp/otp.go
generated
vendored
Normal file
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* Copyright 2014 Paul Querna
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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 otp
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"image"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/boombuler/barcode"
|
||||
"github.com/boombuler/barcode/qr"
|
||||
)
|
||||
|
||||
// Error when attempting to convert the secret from base32 to raw bytes.
|
||||
var ErrValidateSecretInvalidBase32 = errors.New("Decoding of secret as base32 failed.")
|
||||
|
||||
// The user provided passcode length was not expected.
|
||||
var ErrValidateInputInvalidLength = errors.New("Input length unexpected")
|
||||
|
||||
// When generating a Key, the Issuer must be set.
|
||||
var ErrGenerateMissingIssuer = errors.New("Issuer must be set")
|
||||
|
||||
// When generating a Key, the Account Name must be set.
|
||||
var ErrGenerateMissingAccountName = errors.New("AccountName must be set")
|
||||
|
||||
// Key represents an TOTP or HTOP key.
|
||||
type Key struct {
|
||||
orig string
|
||||
url *url.URL
|
||||
}
|
||||
|
||||
// NewKeyFromURL creates a new Key from an TOTP or HOTP url.
|
||||
//
|
||||
// The URL format is documented here:
|
||||
// https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
||||
//
|
||||
func NewKeyFromURL(orig string) (*Key, error) {
|
||||
s := strings.TrimSpace(orig)
|
||||
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Key{
|
||||
orig: s,
|
||||
url: u,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (k *Key) String() string {
|
||||
return k.orig
|
||||
}
|
||||
|
||||
// Image returns an QR-Code image of the specified width and height,
|
||||
// suitable for use by many clients like Google-Authenricator
|
||||
// to enroll a user's TOTP/HOTP key.
|
||||
func (k *Key) Image(width int, height int) (image.Image, error) {
|
||||
b, err := qr.Encode(k.orig, qr.M, qr.Auto)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b, err = barcode.Scale(b, width, height)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Type returns "hotp" or "totp".
|
||||
func (k *Key) Type() string {
|
||||
return k.url.Host
|
||||
}
|
||||
|
||||
// Issuer returns the name of the issuing organization.
|
||||
func (k *Key) Issuer() string {
|
||||
q := k.url.Query()
|
||||
|
||||
issuer := q.Get("issuer")
|
||||
|
||||
if issuer != "" {
|
||||
return issuer
|
||||
}
|
||||
|
||||
p := strings.TrimPrefix(k.url.Path, "/")
|
||||
i := strings.Index(p, ":")
|
||||
|
||||
if i == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return p[:i]
|
||||
}
|
||||
|
||||
// AccountName returns the name of the user's account.
|
||||
func (k *Key) AccountName() string {
|
||||
p := strings.TrimPrefix(k.url.Path, "/")
|
||||
i := strings.Index(p, ":")
|
||||
|
||||
if i == -1 {
|
||||
return p
|
||||
}
|
||||
|
||||
return p[i+1:]
|
||||
}
|
||||
|
||||
// Secret returns the opaque secret for this Key.
|
||||
func (k *Key) Secret() string {
|
||||
q := k.url.Query()
|
||||
|
||||
return q.Get("secret")
|
||||
}
|
||||
|
||||
// Period returns a tiny int representing the rotation time in seconds.
|
||||
func (k *Key) Period() uint64 {
|
||||
q := k.url.Query()
|
||||
|
||||
if u, err := strconv.ParseUint(q.Get("period"), 10, 64); err == nil {
|
||||
return u
|
||||
}
|
||||
|
||||
// If no period is defined 30 seconds is the default per (rfc6238)
|
||||
return 30
|
||||
}
|
||||
|
||||
// Digits returns a tiny int representing the number of OTP digits.
|
||||
func (k *Key) Digits() Digits {
|
||||
q := k.url.Query()
|
||||
|
||||
if u, err := strconv.ParseUint(q.Get("digits"), 10, 64); err == nil {
|
||||
switch u {
|
||||
case 8:
|
||||
return DigitsEight
|
||||
default:
|
||||
return DigitsSix
|
||||
}
|
||||
}
|
||||
|
||||
// Six is the most common value.
|
||||
return DigitsSix
|
||||
}
|
||||
|
||||
// Algorithm returns the algorithm used or the default (SHA1).
|
||||
func (k *Key) Algorithm() Algorithm {
|
||||
q := k.url.Query()
|
||||
|
||||
a := strings.ToLower(q.Get("algorithm"))
|
||||
switch a {
|
||||
case "md5":
|
||||
return AlgorithmMD5
|
||||
case "sha256":
|
||||
return AlgorithmSHA256
|
||||
case "sha512":
|
||||
return AlgorithmSHA512
|
||||
default:
|
||||
return AlgorithmSHA1
|
||||
}
|
||||
}
|
||||
|
||||
// URL returns the OTP URL as a string
|
||||
func (k *Key) URL() string {
|
||||
return k.url.String()
|
||||
}
|
||||
|
||||
// Algorithm represents the hashing function to use in the HMAC
|
||||
// operation needed for OTPs.
|
||||
type Algorithm int
|
||||
|
||||
const (
|
||||
// AlgorithmSHA1 should be used for compatibility with Google Authenticator.
|
||||
//
|
||||
// See https://github.com/pquerna/otp/issues/55 for additional details.
|
||||
AlgorithmSHA1 Algorithm = iota
|
||||
AlgorithmSHA256
|
||||
AlgorithmSHA512
|
||||
AlgorithmMD5
|
||||
)
|
||||
|
||||
func (a Algorithm) String() string {
|
||||
switch a {
|
||||
case AlgorithmSHA1:
|
||||
return "SHA1"
|
||||
case AlgorithmSHA256:
|
||||
return "SHA256"
|
||||
case AlgorithmSHA512:
|
||||
return "SHA512"
|
||||
case AlgorithmMD5:
|
||||
return "MD5"
|
||||
}
|
||||
panic("unreached")
|
||||
}
|
||||
|
||||
func (a Algorithm) Hash() hash.Hash {
|
||||
switch a {
|
||||
case AlgorithmSHA1:
|
||||
return sha1.New()
|
||||
case AlgorithmSHA256:
|
||||
return sha256.New()
|
||||
case AlgorithmSHA512:
|
||||
return sha512.New()
|
||||
case AlgorithmMD5:
|
||||
return md5.New()
|
||||
}
|
||||
panic("unreached")
|
||||
}
|
||||
|
||||
// Digits represents the number of digits present in the
|
||||
// user's OTP passcode. Six and Eight are the most common values.
|
||||
type Digits int
|
||||
|
||||
const (
|
||||
DigitsSix Digits = 6
|
||||
DigitsEight Digits = 8
|
||||
)
|
||||
|
||||
// Format converts an integer into the zero-filled size for this Digits.
|
||||
func (d Digits) Format(in int32) string {
|
||||
f := fmt.Sprintf("%%0%dd", d)
|
||||
return fmt.Sprintf(f, in)
|
||||
}
|
||||
|
||||
// Length returns the number of characters for this Digits.
|
||||
func (d Digits) Length() int {
|
||||
return int(d)
|
||||
}
|
||||
|
||||
func (d Digits) String() string {
|
||||
return fmt.Sprintf("%d", d)
|
||||
}
|
207
vendor/github.com/pquerna/otp/totp/totp.go
generated
vendored
Normal file
207
vendor/github.com/pquerna/otp/totp/totp.go
generated
vendored
Normal file
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* Copyright 2014 Paul Querna
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* http://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 totp
|
||||
|
||||
import (
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/hotp"
|
||||
"github.com/pquerna/otp/internal"
|
||||
"io"
|
||||
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Validate a TOTP using the current time.
|
||||
// A shortcut for ValidateCustom, Validate uses a configuration
|
||||
// that is compatible with Google-Authenticator and most clients.
|
||||
func Validate(passcode string, secret string) bool {
|
||||
rv, _ := ValidateCustom(
|
||||
passcode,
|
||||
secret,
|
||||
time.Now().UTC(),
|
||||
ValidateOpts{
|
||||
Period: 30,
|
||||
Skew: 1,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
},
|
||||
)
|
||||
return rv
|
||||
}
|
||||
|
||||
// GenerateCode creates a TOTP token using the current time.
|
||||
// A shortcut for GenerateCodeCustom, GenerateCode uses a configuration
|
||||
// that is compatible with Google-Authenticator and most clients.
|
||||
func GenerateCode(secret string, t time.Time) (string, error) {
|
||||
return GenerateCodeCustom(secret, t, ValidateOpts{
|
||||
Period: 30,
|
||||
Skew: 1,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateOpts provides options for ValidateCustom().
|
||||
type ValidateOpts struct {
|
||||
// Number of seconds a TOTP hash is valid for. Defaults to 30 seconds.
|
||||
Period uint
|
||||
// Periods before or after the current time to allow. Value of 1 allows up to Period
|
||||
// of either side of the specified time. Defaults to 0 allowed skews. Values greater
|
||||
// than 1 are likely sketchy.
|
||||
Skew uint
|
||||
// Digits as part of the input. Defaults to 6.
|
||||
Digits otp.Digits
|
||||
// Algorithm to use for HMAC. Defaults to SHA1.
|
||||
Algorithm otp.Algorithm
|
||||
}
|
||||
|
||||
// GenerateCodeCustom takes a timepoint and produces a passcode using a
|
||||
// secret and the provided opts. (Under the hood, this is making an adapted
|
||||
// call to hotp.GenerateCodeCustom)
|
||||
func GenerateCodeCustom(secret string, t time.Time, opts ValidateOpts) (passcode string, err error) {
|
||||
if opts.Period == 0 {
|
||||
opts.Period = 30
|
||||
}
|
||||
counter := uint64(math.Floor(float64(t.Unix()) / float64(opts.Period)))
|
||||
passcode, err = hotp.GenerateCodeCustom(secret, counter, hotp.ValidateOpts{
|
||||
Digits: opts.Digits,
|
||||
Algorithm: opts.Algorithm,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return passcode, nil
|
||||
}
|
||||
|
||||
// ValidateCustom validates a TOTP given a user specified time and custom options.
|
||||
// Most users should use Validate() to provide an interpolatable TOTP experience.
|
||||
func ValidateCustom(passcode string, secret string, t time.Time, opts ValidateOpts) (bool, error) {
|
||||
if opts.Period == 0 {
|
||||
opts.Period = 30
|
||||
}
|
||||
|
||||
counters := []uint64{}
|
||||
counter := int64(math.Floor(float64(t.Unix()) / float64(opts.Period)))
|
||||
|
||||
counters = append(counters, uint64(counter))
|
||||
for i := 1; i <= int(opts.Skew); i++ {
|
||||
counters = append(counters, uint64(counter+int64(i)))
|
||||
counters = append(counters, uint64(counter-int64(i)))
|
||||
}
|
||||
|
||||
for _, counter := range counters {
|
||||
rv, err := hotp.ValidateCustom(passcode, counter, secret, hotp.ValidateOpts{
|
||||
Digits: opts.Digits,
|
||||
Algorithm: opts.Algorithm,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if rv == true {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GenerateOpts provides options for Generate(). The default values
|
||||
// are compatible with Google-Authenticator.
|
||||
type GenerateOpts struct {
|
||||
// Name of the issuing Organization/Company.
|
||||
Issuer string
|
||||
// Name of the User's Account (eg, email address)
|
||||
AccountName string
|
||||
// Number of seconds a TOTP hash is valid for. Defaults to 30 seconds.
|
||||
Period uint
|
||||
// Size in size of the generated Secret. Defaults to 20 bytes.
|
||||
SecretSize uint
|
||||
// Secret to store. Defaults to a randomly generated secret of SecretSize. You should generally leave this empty.
|
||||
Secret []byte
|
||||
// Digits to request. Defaults to 6.
|
||||
Digits otp.Digits
|
||||
// Algorithm to use for HMAC. Defaults to SHA1.
|
||||
Algorithm otp.Algorithm
|
||||
// Reader to use for generating TOTP Key.
|
||||
Rand io.Reader
|
||||
}
|
||||
|
||||
var b32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// Generate a new TOTP Key.
|
||||
func Generate(opts GenerateOpts) (*otp.Key, error) {
|
||||
// url encode the Issuer/AccountName
|
||||
if opts.Issuer == "" {
|
||||
return nil, otp.ErrGenerateMissingIssuer
|
||||
}
|
||||
|
||||
if opts.AccountName == "" {
|
||||
return nil, otp.ErrGenerateMissingAccountName
|
||||
}
|
||||
|
||||
if opts.Period == 0 {
|
||||
opts.Period = 30
|
||||
}
|
||||
|
||||
if opts.SecretSize == 0 {
|
||||
opts.SecretSize = 20
|
||||
}
|
||||
|
||||
if opts.Digits == 0 {
|
||||
opts.Digits = otp.DigitsSix
|
||||
}
|
||||
|
||||
if opts.Rand == nil {
|
||||
opts.Rand = rand.Reader
|
||||
}
|
||||
|
||||
// otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
|
||||
|
||||
v := url.Values{}
|
||||
if len(opts.Secret) != 0 {
|
||||
v.Set("secret", b32NoPadding.EncodeToString(opts.Secret))
|
||||
} else {
|
||||
secret := make([]byte, opts.SecretSize)
|
||||
_, err := opts.Rand.Read(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Set("secret", b32NoPadding.EncodeToString(secret))
|
||||
}
|
||||
|
||||
v.Set("issuer", opts.Issuer)
|
||||
v.Set("period", strconv.FormatUint(uint64(opts.Period), 10))
|
||||
v.Set("algorithm", opts.Algorithm.String())
|
||||
v.Set("digits", opts.Digits.String())
|
||||
|
||||
u := url.URL{
|
||||
Scheme: "otpauth",
|
||||
Host: "totp",
|
||||
Path: "/" + opts.Issuer + ":" + opts.AccountName,
|
||||
RawQuery: internal.EncodeQuery(v),
|
||||
}
|
||||
|
||||
return otp.NewKeyFromURL(u.String())
|
||||
}
|
11
vendor/modules.txt
vendored
11
vendor/modules.txt
vendored
|
@ -297,6 +297,11 @@ github.com/aymerick/douceur/parser
|
|||
# github.com/beorn7/perks v1.0.1
|
||||
## explicit; go 1.11
|
||||
github.com/beorn7/perks/quantile
|
||||
# github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc
|
||||
## explicit
|
||||
github.com/boombuler/barcode
|
||||
github.com/boombuler/barcode/qr
|
||||
github.com/boombuler/barcode/utils
|
||||
# github.com/buckket/go-blurhash v1.1.0
|
||||
## explicit; go 1.14
|
||||
github.com/buckket/go-blurhash
|
||||
|
@ -730,6 +735,12 @@ github.com/pkg/errors
|
|||
# github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
|
||||
## explicit
|
||||
github.com/pmezard/go-difflib/difflib
|
||||
# github.com/pquerna/otp v1.4.0
|
||||
## explicit; go 1.12
|
||||
github.com/pquerna/otp
|
||||
github.com/pquerna/otp/hotp
|
||||
github.com/pquerna/otp/internal
|
||||
github.com/pquerna/otp/totp
|
||||
# github.com/prometheus/client_golang v1.21.1
|
||||
## explicit; go 1.21
|
||||
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil
|
||||
|
|
|
@ -143,15 +143,20 @@ const gtsBaseQuery: BaseQueryFn<
|
|||
return headers;
|
||||
},
|
||||
responseHandler: (response) => {
|
||||
// Return just text if caller has
|
||||
// set a custom accept content-type.
|
||||
if (accept !== "application/json") {
|
||||
return response.text();
|
||||
switch (true) {
|
||||
case (accept === "application/json"):
|
||||
// return good old
|
||||
// fashioned JSON baby!
|
||||
return response.json();
|
||||
case (accept.startsWith("image/")):
|
||||
// It's an image,
|
||||
// return the blob.
|
||||
return response.blob();
|
||||
default:
|
||||
// God knows what it
|
||||
// is, just return text.
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// Else return good old
|
||||
// fashioned JSON baby!
|
||||
return response.json();
|
||||
},
|
||||
})(args, api, extraOptions);
|
||||
};
|
||||
|
@ -174,6 +179,7 @@ export const gtsApi = createApi({
|
|||
"DomainPermissionExclude",
|
||||
"DomainPermissionSubscription",
|
||||
"TokenInfo",
|
||||
"User",
|
||||
],
|
||||
endpoints: (build) => ({
|
||||
instanceV1: build.query<InstanceV1, void>({
|
||||
|
|
|
@ -58,7 +58,8 @@ const extended = gtsApi.injectEndpoints({
|
|||
}),
|
||||
|
||||
user: build.query<User, void>({
|
||||
query: () => ({url: `/api/v1/user`})
|
||||
query: () => ({url: `/api/v1/user`}),
|
||||
providesTags: ["User"],
|
||||
}),
|
||||
|
||||
passwordChange: build.mutation({
|
||||
|
|
82
web/source/settings/lib/query/user/twofactor.ts
Normal file
82
web/source/settings/lib/query/user/twofactor.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { gtsApi } from "../gts-api";
|
||||
import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
|
||||
|
||||
const extended = gtsApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
twoFactorQRCodeURI: build.mutation<string, void>({
|
||||
query: () => ({
|
||||
url: `/api/v1/user/2fa/qruri`,
|
||||
acceptContentType: "text/plain",
|
||||
})
|
||||
}),
|
||||
|
||||
twoFactorQRCodePng: build.mutation<string, void>({
|
||||
async queryFn(_arg, _api, _extraOpts, fetchWithBQ) {
|
||||
const blobRes = await fetchWithBQ({
|
||||
url: `/api/v1/user/2fa/qr.png`,
|
||||
acceptContentType: "image/png",
|
||||
});
|
||||
if (blobRes.error) {
|
||||
return { error: blobRes.error as FetchBaseQueryError };
|
||||
}
|
||||
|
||||
if (blobRes.meta?.response?.status !== 200) {
|
||||
return { error: blobRes.data };
|
||||
}
|
||||
|
||||
const blob = blobRes.data as Blob;
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
return { data: url };
|
||||
},
|
||||
}),
|
||||
|
||||
twoFactorEnable: build.mutation<string[], { password: string }>({
|
||||
query: (formData) => ({
|
||||
method: "POST",
|
||||
url: `/api/v1/user/2fa/enable`,
|
||||
asForm: true,
|
||||
body: formData,
|
||||
discardEmpty: true
|
||||
})
|
||||
}),
|
||||
|
||||
twoFactorDisable: build.mutation<void, { password: string }>({
|
||||
query: (formData) => ({
|
||||
method: "POST",
|
||||
url: `/api/v1/user/2fa/disable`,
|
||||
asForm: true,
|
||||
body: formData,
|
||||
discardEmpty: true,
|
||||
acceptContentType: "*/*",
|
||||
}),
|
||||
invalidatesTags: ["User"]
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
export const {
|
||||
useTwoFactorQRCodeURIMutation,
|
||||
useTwoFactorQRCodePngMutation,
|
||||
useTwoFactorEnableMutation,
|
||||
useTwoFactorDisableMutation,
|
||||
} = extended;
|
|
@ -31,4 +31,5 @@ export interface User {
|
|||
disabled: boolean;
|
||||
approved: boolean;
|
||||
reset_password_sent_at?: string;
|
||||
two_factor_enabled_at?: string;
|
||||
}
|
||||
|
|
|
@ -71,7 +71,8 @@ export const store = configureStore({
|
|||
PERSIST,
|
||||
PURGE,
|
||||
REGISTER,
|
||||
]
|
||||
],
|
||||
ignoredPaths: ['api.queries.twoFactorQRCodePng(undefined).data.data'],
|
||||
}
|
||||
}).concat(gtsApi.middleware);
|
||||
}
|
||||
|
|
123
web/source/settings/views/user/account/email.tsx
Normal file
123
web/source/settings/views/user/account/email.tsx
Normal file
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { useEmailChangeMutation } from "../../../lib/query/user";
|
||||
import { User } from "../../../lib/types/user";
|
||||
|
||||
export default function EmailChange({user, oidcEnabled}: { user: User, oidcEnabled?: boolean }) {
|
||||
const form = {
|
||||
currentEmail: useTextInput("current_email", {
|
||||
defaultValue: user.email,
|
||||
nosubmit: true
|
||||
}),
|
||||
newEmail: useTextInput("new_email", {
|
||||
validator: (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (value.toLowerCase() === user.email?.toLowerCase()) {
|
||||
return "cannot change to your existing address";
|
||||
}
|
||||
|
||||
if (value.toLowerCase() === user.unconfirmed_email?.toLowerCase()) {
|
||||
return "you already have a pending email address change to this address";
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
}),
|
||||
password: useTextInput("password"),
|
||||
};
|
||||
const [submitForm, result] = useFormSubmit(form, useEmailChangeMutation());
|
||||
|
||||
return (
|
||||
<form className="change-email" onSubmit={submitForm}>
|
||||
<div className="form-section-docs">
|
||||
<h3>Change Email</h3>
|
||||
{ oidcEnabled && <p>
|
||||
This instance is running with OIDC as its authorization + identity provider.
|
||||
<br/>
|
||||
You can still change your email address using this settings panel,
|
||||
but it will only affect which address GoToSocial uses to contact you,
|
||||
not the email address you use to log in.
|
||||
<br/>
|
||||
To change the email address you use to log in, contact your OIDC provider.
|
||||
</p> }
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#email-change"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about this (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{ (user.unconfirmed_email && user.unconfirmed_email !== user.email) && <>
|
||||
<div className="info">
|
||||
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||
<b>
|
||||
You currently have a pending email address
|
||||
change to the address: {user.unconfirmed_email}
|
||||
<br />
|
||||
To confirm {user.unconfirmed_email} as your new
|
||||
address for this account, please check your email inbox.
|
||||
</b>
|
||||
</div>
|
||||
</> }
|
||||
|
||||
<TextInput
|
||||
type="email"
|
||||
name="current-email"
|
||||
field={form.currentEmail}
|
||||
label="Current email address"
|
||||
autoComplete="none"
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
type="password"
|
||||
name="password"
|
||||
field={form.password}
|
||||
label="Current password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
type="email"
|
||||
name="new-email"
|
||||
field={form.newEmail}
|
||||
label="New email address"
|
||||
autoComplete="none"
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
disabled={!form.password || !form.newEmail || !form.newEmail.valid}
|
||||
label="Change email address"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
75
web/source/settings/views/user/account/index.tsx
Normal file
75
web/source/settings/views/user/account/index.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import EmailChange from "./email";
|
||||
import PasswordChange from "./password";
|
||||
import TwoFactor from "./twofactor";
|
||||
import { useInstanceV1Query } from "../../../lib/query/gts-api";
|
||||
import Loading from "../../../components/loading";
|
||||
import { useUserQuery } from "../../../lib/query/user";
|
||||
|
||||
export default function Account() {
|
||||
// Load instance data.
|
||||
const {
|
||||
data: instance,
|
||||
isFetching: isFetchingInstance,
|
||||
isLoading: isLoadingInstance
|
||||
} = useInstanceV1Query();
|
||||
|
||||
// Load user data.
|
||||
const {
|
||||
data: user,
|
||||
isFetching: isFetchingUser,
|
||||
isLoading: isLoadingUser
|
||||
} = useUserQuery();
|
||||
|
||||
if (
|
||||
(isFetchingInstance || isLoadingInstance) ||
|
||||
(isFetchingUser || isLoadingUser)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (user === undefined) {
|
||||
throw "could not fetch user";
|
||||
}
|
||||
|
||||
if (instance === undefined) {
|
||||
throw "could not fetch instance";
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Account Settings</h1>
|
||||
<EmailChange
|
||||
oidcEnabled={instance.configuration.oidc_enabled}
|
||||
user={user}
|
||||
/>
|
||||
<PasswordChange
|
||||
oidcEnabled={instance.configuration.oidc_enabled}
|
||||
/>
|
||||
<TwoFactor
|
||||
oidcEnabled={instance.configuration.oidc_enabled}
|
||||
twoFactorEnabledAt={user.two_factor_enabled_at}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
103
web/source/settings/views/user/account/password.tsx
Normal file
103
web/source/settings/views/user/account/password.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { usePasswordChangeMutation } from "../../../lib/query/user";
|
||||
|
||||
export default function PasswordChange({ oidcEnabled }: { oidcEnabled?: boolean }) {
|
||||
const form = {
|
||||
oldPassword: useTextInput("old_password"),
|
||||
newPassword: useTextInput("new_password", {
|
||||
validator(val) {
|
||||
if (val != "" && val == form.oldPassword.value) {
|
||||
return "New password same as old password";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const verifyNewPassword = useTextInput("verifyNewPassword", {
|
||||
validator(val) {
|
||||
if (val != "" && val != form.newPassword.value) {
|
||||
return "Passwords do not match";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, usePasswordChangeMutation());
|
||||
|
||||
return (
|
||||
<form className="change-password" onSubmit={submitForm}>
|
||||
<div className="form-section-docs">
|
||||
<h3>Change Password</h3>
|
||||
{ oidcEnabled && <p>
|
||||
This instance is running with OIDC as its authorization + identity provider.
|
||||
<br/>
|
||||
This means <strong>you cannot change your password using this settings panel</strong>.
|
||||
<br/>
|
||||
To change your password, you should instead contact your OIDC provider.
|
||||
</p> }
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#password-change"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about this (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
type="password"
|
||||
name="password"
|
||||
field={form.oldPassword}
|
||||
label="Current password"
|
||||
autoComplete="current-password"
|
||||
disabled={oidcEnabled}
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
name="newPassword"
|
||||
field={form.newPassword}
|
||||
label="New password"
|
||||
autoComplete="new-password"
|
||||
disabled={oidcEnabled}
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
name="confirmNewPassword"
|
||||
field={verifyNewPassword}
|
||||
label="Confirm new password"
|
||||
autoComplete="new-password"
|
||||
disabled={oidcEnabled}
|
||||
/>
|
||||
<MutationButton
|
||||
label="Change password"
|
||||
result={result}
|
||||
disabled={oidcEnabled ?? false}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
308
web/source/settings/views/user/account/twofactor.tsx
Normal file
308
web/source/settings/views/user/account/twofactor.tsx
Normal file
|
@ -0,0 +1,308 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import {
|
||||
useTwoFactorQRCodeURIMutation,
|
||||
useTwoFactorDisableMutation,
|
||||
useTwoFactorEnableMutation,
|
||||
useTwoFactorQRCodePngMutation,
|
||||
} from "../../../lib/query/user/twofactor";
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import Loading from "../../../components/loading";
|
||||
import { Error } from "../../../components/error";
|
||||
import { HighlightedCode } from "../../../components/highlightedcode";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { gtsApi } from "../../../lib/query/gts-api";
|
||||
|
||||
interface TwoFactorProps {
|
||||
twoFactorEnabledAt?: string,
|
||||
oidcEnabled?: boolean,
|
||||
}
|
||||
|
||||
export default function TwoFactor({ twoFactorEnabledAt, oidcEnabled }: TwoFactorProps) {
|
||||
switch (true) {
|
||||
case oidcEnabled:
|
||||
// Can't enable if OIDC is in place.
|
||||
return <CannotEnable />;
|
||||
case twoFactorEnabledAt !== undefined:
|
||||
// Already enabled. Show the disable form.
|
||||
return <DisableForm twoFactorEnabledAt={twoFactorEnabledAt as string} />;
|
||||
default:
|
||||
// Not enabled. Show the enable form.
|
||||
return <EnableForm />;
|
||||
}
|
||||
}
|
||||
|
||||
function CannotEnable() {
|
||||
return (
|
||||
<form>
|
||||
<TwoFactorHeader
|
||||
blurb={
|
||||
<p>
|
||||
OIDC is enabled for your instance. To enable 2FA, you must use your
|
||||
instance's OIDC provider instead. Poke your admin for more information.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EnableForm() {
|
||||
const form = { code: useTextInput("code") };
|
||||
const [ recoveryCodes, setRecoveryCodes ] = useState<string>();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Prepare trigger to submit the code and enable 2FA.
|
||||
// If the enable call is a success, set the recovery
|
||||
// codes state to a nice newline-separated text.
|
||||
const [submitForm, result] = useFormSubmit(form, useTwoFactorEnableMutation(), {
|
||||
changedOnly: true,
|
||||
onFinish: (res) => {
|
||||
const codes = res.data as string[];
|
||||
if (!codes) {
|
||||
return;
|
||||
}
|
||||
setRecoveryCodes(codes.join("\n"));
|
||||
},
|
||||
});
|
||||
|
||||
// When the component is unmounted, clear the user
|
||||
// cache if 2FA was just enabled. This will prevent
|
||||
// the recovery codes from being shown again.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recoveryCodes) {
|
||||
dispatch(gtsApi.util.invalidateTags(["User"]));
|
||||
}
|
||||
};
|
||||
}, [recoveryCodes, dispatch]);
|
||||
|
||||
return (
|
||||
<form className="2fa-enable-form" onSubmit={submitForm}>
|
||||
<TwoFactorHeader
|
||||
blurb={
|
||||
<p>
|
||||
You can use this form to enable 2FA for your account.
|
||||
<br/>
|
||||
In your authenticator app, either scan the QR code, or copy
|
||||
the 2FA secret manually, and then enter a 2FA code to verify.
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
If the enable call was successful then recovery
|
||||
codes will now be set. Display these to the user.
|
||||
|
||||
If the call hasn't been made yet, show the
|
||||
form to enable 2FA as normal.
|
||||
*/}
|
||||
{ recoveryCodes
|
||||
? <>
|
||||
<p>
|
||||
<b>Two-factor authentication is now enabled for your account!</b>
|
||||
<br/>From now on, you will need to provide a code from your authenticator app whenever you want to sign in.
|
||||
<br/>If you lose access to your authenticator app, you may also sign in by providing one of the below one-time recovery codes instead of a 2FA code.
|
||||
<br/>Once you have used a recovery code once, you will not be able to use it again!
|
||||
<br/><strong>You will not be shown these codes again, so copy them now into a safe place! Treat them like passwords!</strong>
|
||||
</p>
|
||||
<details>
|
||||
<summary>Show / hide codes</summary>
|
||||
<HighlightedCode
|
||||
code={recoveryCodes}
|
||||
lang="text"
|
||||
/>
|
||||
</details>
|
||||
</>
|
||||
: <>
|
||||
<CodePng />
|
||||
<Secret />
|
||||
<TextInput
|
||||
name="code"
|
||||
field={form.code}
|
||||
label="2FA code from your authenticator app (6 numbers)"
|
||||
autoComplete="off"
|
||||
disabled={false}
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
pattern="^\d{6}$"
|
||||
readOnly={false}
|
||||
/>
|
||||
<MutationButton
|
||||
label="Enable 2FA"
|
||||
result={result}
|
||||
disabled={false}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Load and show QR code png only when
|
||||
// the "Show QR Code" button is clicked.
|
||||
function CodePng() {
|
||||
const [
|
||||
getPng, {
|
||||
isUninitialized,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
data,
|
||||
error,
|
||||
reset,
|
||||
}
|
||||
] = useTwoFactorQRCodePngMutation();
|
||||
|
||||
const [ content, setContent ] = useState<ReactNode>();
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setContent(<Loading />);
|
||||
} else if (isSuccess && data) {
|
||||
setContent(<img src={data} height="256" width="256" />);
|
||||
} else {
|
||||
setContent(<Error error={error} />);
|
||||
}
|
||||
}, [isLoading, isSuccess, data, error]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ isUninitialized
|
||||
? <button
|
||||
disabled={false}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
getPng();
|
||||
}}
|
||||
>Show QR Code</button>
|
||||
: <button
|
||||
disabled={false}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
setContent(null);
|
||||
}}
|
||||
>Hide QR Code</button>
|
||||
}
|
||||
{ content }
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Get 2fa secret from server and
|
||||
// load it into clipboard on click.
|
||||
function Secret() {
|
||||
const [
|
||||
getURI,
|
||||
{
|
||||
isUninitialized,
|
||||
isSuccess,
|
||||
data,
|
||||
error,
|
||||
reset,
|
||||
},
|
||||
] = useTwoFactorQRCodeURIMutation();
|
||||
|
||||
const [ buttonContents, setButtonContents ] = useState<ReactNode>();
|
||||
useEffect(() => {
|
||||
if (isUninitialized) {
|
||||
setButtonContents("Copy 2FA secret to clipboard");
|
||||
} else if (isSuccess && data) {
|
||||
const url = new URL(data);
|
||||
const secret = url.searchParams.get("secret");
|
||||
if (!secret) {
|
||||
throw "null secret";
|
||||
}
|
||||
navigator.clipboard.writeText(secret);
|
||||
setButtonContents("Copied!");
|
||||
setTimeout(() => { reset(); }, 3000);
|
||||
} else {
|
||||
setButtonContents(<Error error={error} />);
|
||||
}
|
||||
}, [isUninitialized, isSuccess, data, reset, error]);
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={false}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
getURI();
|
||||
}}
|
||||
>{buttonContents}</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DisableForm({ twoFactorEnabledAt }: { twoFactorEnabledAt: string }) {
|
||||
const enabledAt = useMemo(() => {
|
||||
const enabledAt = new Date(twoFactorEnabledAt);
|
||||
return <time dateTime={twoFactorEnabledAt}>{enabledAt.toDateString()}</time>;
|
||||
}, [twoFactorEnabledAt]);
|
||||
|
||||
const form = {
|
||||
password: useTextInput("password"),
|
||||
};
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, useTwoFactorDisableMutation());
|
||||
return (
|
||||
<form className="2fa-disable-form" onSubmit={submitForm}>
|
||||
<TwoFactorHeader
|
||||
blurb={
|
||||
<p>
|
||||
Two-factor auth is enabled for your account, since <b>{enabledAt}</b>.
|
||||
<br/>To disable 2FA, supply your password for verification and click "Disable 2FA".
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
name="password"
|
||||
field={form.password}
|
||||
label="Current password"
|
||||
autoComplete="current-password"
|
||||
disabled={false}
|
||||
/>
|
||||
<MutationButton
|
||||
label="Disable 2FA"
|
||||
result={result}
|
||||
disabled={false}
|
||||
className="danger"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TwoFactorHeader({ blurb }: { blurb: ReactNode }) {
|
||||
return (
|
||||
<div className="form-section-docs">
|
||||
<h3>Two-Factor Authentication</h3>
|
||||
{blurb}
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#two-factor"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about this (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,264 +0,0 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useTextInput } from "../../lib/form";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import { TextInput } from "../../components/form/inputs";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
import { useEmailChangeMutation, usePasswordChangeMutation, useUserQuery } from "../../lib/query/user";
|
||||
import Loading from "../../components/loading";
|
||||
import { User } from "../../lib/types/user";
|
||||
import { useInstanceV1Query } from "../../lib/query/gts-api";
|
||||
|
||||
export default function EmailPassword() {
|
||||
return (
|
||||
<>
|
||||
<h1>Email & Password Settings</h1>
|
||||
<EmailChange />
|
||||
<PasswordChange />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordChange() {
|
||||
// Load instance data.
|
||||
const {
|
||||
data: instance,
|
||||
isFetching: isFetchingInstance,
|
||||
isLoading: isLoadingInstance
|
||||
} = useInstanceV1Query();
|
||||
if (isFetchingInstance || isLoadingInstance) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (instance === undefined) {
|
||||
throw "could not fetch instance";
|
||||
}
|
||||
|
||||
return <PasswordChangeForm oidcEnabled={instance.configuration.oidc_enabled} />;
|
||||
}
|
||||
|
||||
function PasswordChangeForm({ oidcEnabled }: { oidcEnabled?: boolean }) {
|
||||
const form = {
|
||||
oldPassword: useTextInput("old_password"),
|
||||
newPassword: useTextInput("new_password", {
|
||||
validator(val) {
|
||||
if (val != "" && val == form.oldPassword.value) {
|
||||
return "New password same as old password";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
const verifyNewPassword = useTextInput("verifyNewPassword", {
|
||||
validator(val) {
|
||||
if (val != "" && val != form.newPassword.value) {
|
||||
return "Passwords do not match";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const [submitForm, result] = useFormSubmit(form, usePasswordChangeMutation());
|
||||
|
||||
return (
|
||||
<form className="change-password" onSubmit={submitForm}>
|
||||
<div className="form-section-docs">
|
||||
<h3>Change Password</h3>
|
||||
{ oidcEnabled && <p>
|
||||
This instance is running with OIDC as its authorization + identity provider.
|
||||
<br/>
|
||||
This means <strong>you cannot change your password using this settings panel</strong>.
|
||||
<br/>
|
||||
To change your password, you should instead contact your OIDC provider.
|
||||
</p> }
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#password-change"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about this (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
type="password"
|
||||
name="password"
|
||||
field={form.oldPassword}
|
||||
label="Current password"
|
||||
autoComplete="current-password"
|
||||
disabled={oidcEnabled}
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
name="newPassword"
|
||||
field={form.newPassword}
|
||||
label="New password"
|
||||
autoComplete="new-password"
|
||||
disabled={oidcEnabled}
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
name="confirmNewPassword"
|
||||
field={verifyNewPassword}
|
||||
label="Confirm new password"
|
||||
autoComplete="new-password"
|
||||
disabled={oidcEnabled}
|
||||
/>
|
||||
<MutationButton
|
||||
label="Change password"
|
||||
result={result}
|
||||
disabled={oidcEnabled ?? false}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailChange() {
|
||||
// Load instance data.
|
||||
const {
|
||||
data: instance,
|
||||
isFetching: isFetchingInstance,
|
||||
isLoading: isLoadingInstance
|
||||
} = useInstanceV1Query();
|
||||
|
||||
// Load user data.
|
||||
const {
|
||||
data: user,
|
||||
isFetching: isFetchingUser,
|
||||
isLoading: isLoadingUser
|
||||
} = useUserQuery();
|
||||
|
||||
if (
|
||||
(isFetchingInstance || isLoadingInstance) ||
|
||||
(isFetchingUser || isLoadingUser)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (user === undefined) {
|
||||
throw "could not fetch user";
|
||||
}
|
||||
|
||||
if (instance === undefined) {
|
||||
throw "could not fetch instance";
|
||||
}
|
||||
|
||||
return <EmailChangeForm user={user} oidcEnabled={instance.configuration.oidc_enabled} />;
|
||||
}
|
||||
|
||||
function EmailChangeForm({user, oidcEnabled}: { user: User, oidcEnabled?: boolean }) {
|
||||
const form = {
|
||||
currentEmail: useTextInput("current_email", {
|
||||
defaultValue: user.email,
|
||||
nosubmit: true
|
||||
}),
|
||||
newEmail: useTextInput("new_email", {
|
||||
validator: (value: string | undefined) => {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (value.toLowerCase() === user.email?.toLowerCase()) {
|
||||
return "cannot change to your existing address";
|
||||
}
|
||||
|
||||
if (value.toLowerCase() === user.unconfirmed_email?.toLowerCase()) {
|
||||
return "you already have a pending email address change to this address";
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
}),
|
||||
password: useTextInput("password"),
|
||||
};
|
||||
const [submitForm, result] = useFormSubmit(form, useEmailChangeMutation());
|
||||
|
||||
return (
|
||||
<form className="change-email" onSubmit={submitForm}>
|
||||
<div className="form-section-docs">
|
||||
<h3>Change Email</h3>
|
||||
{ oidcEnabled && <p>
|
||||
This instance is running with OIDC as its authorization + identity provider.
|
||||
<br/>
|
||||
You can still change your email address using this settings panel,
|
||||
but it will only affect which address GoToSocial uses to contact you,
|
||||
not the email address you use to log in.
|
||||
<br/>
|
||||
To change the email address you use to log in, contact your OIDC provider.
|
||||
</p> }
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings/#email-change"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about this (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{ (user.unconfirmed_email && user.unconfirmed_email !== user.email) && <>
|
||||
<div className="info">
|
||||
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||
<b>
|
||||
You currently have a pending email address
|
||||
change to the address: {user.unconfirmed_email}
|
||||
<br />
|
||||
To confirm {user.unconfirmed_email} as your new
|
||||
address for this account, please check your email inbox.
|
||||
</b>
|
||||
</div>
|
||||
</> }
|
||||
|
||||
<TextInput
|
||||
type="email"
|
||||
name="current-email"
|
||||
field={form.currentEmail}
|
||||
label="Current email address"
|
||||
autoComplete="none"
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
type="password"
|
||||
name="password"
|
||||
field={form.password}
|
||||
label="Current password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
type="email"
|
||||
name="new-email"
|
||||
field={form.newEmail}
|
||||
label="New email address"
|
||||
autoComplete="none"
|
||||
/>
|
||||
|
||||
<MutationButton
|
||||
disabled={!form.password || !form.newEmail || !form.newEmail.valid}
|
||||
label="Change email address"
|
||||
result={result}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -38,6 +38,11 @@ export default function UserMenu() {
|
|||
itemUrl="profile"
|
||||
icon="fa-user"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Account"
|
||||
itemUrl="account"
|
||||
icon="fa-user-secret"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Posts"
|
||||
itemUrl="posts"
|
||||
|
@ -48,11 +53,6 @@ export default function UserMenu() {
|
|||
itemUrl="interaction_requests"
|
||||
icon="fa-commenting-o"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Email & Password"
|
||||
itemUrl="emailpassword"
|
||||
icon="fa-user-secret"
|
||||
/>
|
||||
<MenuItem
|
||||
name="Migration"
|
||||
itemUrl="migration"
|
||||
|
|
|
@ -19,27 +19,27 @@
|
|||
|
||||
import React from "react";
|
||||
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
|
||||
import { useVerifyCredentialsQuery } from "../../lib/query/login";
|
||||
import { useArrayInput, useTextInput } from "../../lib/form";
|
||||
import { TextInput } from "../../components/form/inputs";
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
import { useAliasAccountMutation, useMoveAccountMutation } from "../../lib/query/user";
|
||||
import { FormContext, useWithFormContext } from "../../lib/form/context";
|
||||
import { store } from "../../redux/store";
|
||||
import { useVerifyCredentialsQuery } from "../../../lib/query/login";
|
||||
import { useArrayInput, useTextInput } from "../../../lib/form";
|
||||
import { TextInput } from "../../../components/form/inputs";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { useAliasAccountMutation, useMoveAccountMutation } from "../../../lib/query/user";
|
||||
import { FormContext, useWithFormContext } from "../../../lib/form/context";
|
||||
import { store } from "../../../redux/store";
|
||||
|
||||
export default function UserMigration() {
|
||||
export default function Migration() {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={useVerifyCredentialsQuery}
|
||||
DataForm={UserMigrationForm}
|
||||
DataForm={MigrationForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMigrationForm({ data: profile }) {
|
||||
function MigrationForm({ data: profile }) {
|
||||
return (
|
||||
<>
|
||||
<h2>Account Migration Settings</h2>
|
|
@ -24,10 +24,10 @@ import {
|
|||
useFileInput,
|
||||
useBoolInput,
|
||||
useFieldArrayInput,
|
||||
} from "../../lib/form";
|
||||
} from "../../../lib/form";
|
||||
|
||||
import useFormSubmit from "../../lib/form/submit";
|
||||
import { useWithFormContext, FormContext } from "../../lib/form/context";
|
||||
import useFormSubmit from "../../../lib/form/submit";
|
||||
import { useWithFormContext, FormContext } from "../../../lib/form/context";
|
||||
|
||||
import {
|
||||
TextInput,
|
||||
|
@ -35,32 +35,36 @@ import {
|
|||
FileInput,
|
||||
Checkbox,
|
||||
Select
|
||||
} from "../../components/form/inputs";
|
||||
} from "../../../components/form/inputs";
|
||||
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
import FakeProfile from "../../components/profile";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
import FakeProfile from "../../../components/profile";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
|
||||
import { useAccountThemesQuery, useDeleteAvatarMutation, useDeleteHeaderMutation } from "../../lib/query/user";
|
||||
import { useUpdateCredentialsMutation } from "../../lib/query/user";
|
||||
import { useVerifyCredentialsQuery } from "../../lib/query/login";
|
||||
import { useInstanceV1Query } from "../../lib/query/gts-api";
|
||||
import { Account } from "../../lib/types/account";
|
||||
import {
|
||||
useAccountThemesQuery,
|
||||
useDeleteAvatarMutation,
|
||||
useDeleteHeaderMutation,
|
||||
} from "../../../lib/query/user";
|
||||
import { useUpdateCredentialsMutation } from "../../../lib/query/user";
|
||||
import { useVerifyCredentialsQuery } from "../../../lib/query/login";
|
||||
import { useInstanceV1Query } from "../../../lib/query/gts-api";
|
||||
import { Account } from "../../../lib/types/account";
|
||||
|
||||
export default function UserProfile() {
|
||||
export default function Profile() {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={useVerifyCredentialsQuery}
|
||||
DataForm={UserProfileForm}
|
||||
DataForm={ProfileForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserProfileFormProps {
|
||||
interface ProfileFormProps {
|
||||
data: Account;
|
||||
}
|
||||
|
||||
function UserProfileForm({ data: profile }: UserProfileFormProps) {
|
||||
function ProfileForm({ data: profile }: ProfileFormProps) {
|
||||
const { data: instance } = useInstanceV1Query();
|
||||
const instanceConfig = React.useMemo(() => {
|
||||
return {
|
|
@ -21,10 +21,9 @@ import React from "react";
|
|||
import { BaseUrlContext, useBaseUrl } from "../../lib/navigation/util";
|
||||
import { Redirect, Route, Router, Switch } from "wouter";
|
||||
import { ErrorBoundary } from "../../lib/navigation/error";
|
||||
import UserProfile from "./profile";
|
||||
import UserMigration from "./migration";
|
||||
import Profile from "./profile/profile";
|
||||
import PostSettings from "./posts";
|
||||
import EmailPassword from "./emailpassword";
|
||||
import Account from "./account";
|
||||
import ExportImport from "./export-import";
|
||||
import InteractionRequests from "./interactions";
|
||||
import InteractionRequestDetail from "./interactions/detail";
|
||||
|
@ -33,11 +32,12 @@ import Applications from "./applications";
|
|||
import NewApp from "./applications/new";
|
||||
import AppDetail from "./applications/detail";
|
||||
import { AppTokenCallback } from "./applications/callback";
|
||||
import Migration from "./migration";
|
||||
|
||||
/**
|
||||
* - /settings/user/profile
|
||||
* - /settings/user/account
|
||||
* - /settings/user/posts
|
||||
* - /settings/user/emailpassword
|
||||
* - /settings/user/migration
|
||||
* - /settings/user/export-import
|
||||
* - /settings/user/tokens
|
||||
|
@ -53,10 +53,10 @@ export default function UserRouter() {
|
|||
<BaseUrlContext.Provider value={absBase}>
|
||||
<Router base={thisBase}>
|
||||
<Switch>
|
||||
<Route path="/profile" component={UserProfile} />
|
||||
<Route path="/profile" component={Profile} />
|
||||
<Route path="/account" component={Account} />
|
||||
<Route path="/posts" component={PostSettings} />
|
||||
<Route path="/emailpassword" component={EmailPassword} />
|
||||
<Route path="/migration" component={UserMigration} />
|
||||
<Route path="/migration" component={Migration} />
|
||||
<Route path="/export-import" component={ExportImport} />
|
||||
<Route path="/tokens" component={Tokens} />
|
||||
</Switch>
|
||||
|
|
46
web/template/2fa.tmpl
Normal file
46
web/template/2fa.tmpl
Normal file
|
@ -0,0 +1,46 @@
|
|||
{{- /*
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/ -}}
|
||||
|
||||
{{- with . }}
|
||||
<main>
|
||||
<section class="with-form" aria-labelledby="two-factor">
|
||||
<h2 id="two-factor">2FA Code Required</h2>
|
||||
<form action="/auth/2fa" method="POST">
|
||||
<p>Hi <b>{{- .user -}}</b>!</p>
|
||||
<p>
|
||||
You have enabled two-factor authentication for your account.
|
||||
To continue signing in, please enter a code from your authenticator app.
|
||||
</p>
|
||||
<p>
|
||||
If you have lost access to your authenticator app, you can enter one of your backup/recovery
|
||||
codes into the form instead to bypass 2FA. Once you have used a code, it cannot be used again.
|
||||
</p>
|
||||
<div class="labelinput">
|
||||
<label for="code">Code</label>
|
||||
<input
|
||||
name="code"
|
||||
required
|
||||
placeholder="Please enter a code from your authenticator app, or a one-time backup/recovery code"
|
||||
>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Submit</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{{- end }}
|
Loading…
Reference in a new issue