+10
-1
models/models.go
+10
-1
models/models.go
···
8
8
"github.com/bluesky-social/indigo/atproto/atcrypto"
9
9
)
10
10
11
+
type TwoFactorType string
12
+
13
+
var (
14
+
TwoFactorTypeNone = TwoFactorType("none")
15
+
TwoFactorTypeEmail = TwoFactorType("email")
16
+
)
17
+
11
18
type Repo struct {
12
19
Did string `gorm:"primaryKey"`
13
20
CreatedAt time.Time
···
29
36
Root []byte
30
37
Preferences []byte
31
38
Deactivated bool
32
-
EmailAuthFactor bool
39
+
TwoFactorCode *string
40
+
TwoFactorCodeExpiresAt *time.Time
41
+
TwoFactorType TwoFactorType `gorm:"default:none"`
33
42
}
34
43
35
44
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
+56
-9
server/handle_account_signin.go
+56
-9
server/handle_account_signin.go
···
2
2
3
3
import (
4
4
"errors"
5
+
"fmt"
5
6
"strings"
7
+
"time"
6
8
7
9
"github.com/bluesky-social/indigo/atproto/syntax"
8
10
"github.com/gorilla/sessions"
···
15
17
)
16
18
17
19
type OauthSigninInput struct {
18
-
Username string `form:"username"`
19
-
Password string `form:"password"`
20
-
QueryParams string `form:"query_params"`
20
+
Username string `form:"username"`
21
+
Password string `form:"password"`
22
+
AuthFactorToken string `form:"token"`
23
+
QueryParams string `form:"query_params"`
21
24
}
22
25
23
26
func (s *Server) getSessionRepoOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, error) {
···
44
47
func getFlashesFromSession(e echo.Context, sess *sessions.Session) map[string]any {
45
48
defer sess.Save(e.Request(), e.Response())
46
49
return map[string]any{
47
-
"errors": sess.Flashes("error"),
48
-
"successes": sess.Flashes("success"),
50
+
"errors": sess.Flashes("error"),
51
+
"successes": sess.Flashes("success"),
52
+
"tokenrequired": sess.Flashes("tokenrequired"),
49
53
}
50
54
}
51
55
···
83
87
idtype = "email"
84
88
}
85
89
90
+
queryParams := ""
91
+
if req.QueryParams != "" {
92
+
queryParams = fmt.Sprintf("?%s", req.QueryParams)
93
+
}
94
+
86
95
// TODO: we should make this a helper since we do it for the base create_session as well
87
96
var repo models.RepoActor
88
97
var err error
···
101
110
sess.AddFlash("Something went wrong!", "error")
102
111
}
103
112
sess.Save(e.Request(), e.Response())
104
-
return e.Redirect(303, "/account/signin")
113
+
return e.Redirect(303, "/account/signin"+queryParams)
105
114
}
106
115
107
116
if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil {
···
111
120
sess.AddFlash("Something went wrong!", "error")
112
121
}
113
122
sess.Save(e.Request(), e.Response())
114
-
return e.Redirect(303, "/account/signin")
123
+
return e.Redirect(303, "/account/signin"+queryParams)
124
+
}
125
+
126
+
// if repo requires 2FA token and one hasn't been provided, return error prompting for one
127
+
if repo.TwoFactorType != models.TwoFactorTypeNone && req.AuthFactorToken == "" {
128
+
err = s.createAndSendTwoFactorCode(ctx, repo)
129
+
if err != nil {
130
+
sess.AddFlash("Something went wrong!", "error")
131
+
sess.Save(e.Request(), e.Response())
132
+
return e.Redirect(303, "/account/signin"+queryParams)
133
+
}
134
+
135
+
sess.AddFlash("requires 2FA token", "tokenrequired")
136
+
sess.Save(e.Request(), e.Response())
137
+
return e.Redirect(303, "/account/signin"+queryParams)
138
+
}
139
+
140
+
// if 2FAis required, now check that the one provided is valid
141
+
if repo.TwoFactorType != models.TwoFactorTypeNone {
142
+
if repo.TwoFactorCode == nil || repo.TwoFactorCodeExpiresAt == nil {
143
+
err = s.createAndSendTwoFactorCode(ctx, repo)
144
+
if err != nil {
145
+
sess.AddFlash("Something went wrong!", "error")
146
+
sess.Save(e.Request(), e.Response())
147
+
return e.Redirect(303, "/account/signin"+queryParams)
148
+
}
149
+
150
+
sess.AddFlash("requires 2FA token", "tokenrequired")
151
+
sess.Save(e.Request(), e.Response())
152
+
return e.Redirect(303, "/account/signin"+queryParams)
153
+
}
154
+
155
+
if *repo.TwoFactorCode != req.AuthFactorToken {
156
+
return helpers.InvalidTokenError(e)
157
+
}
158
+
159
+
if time.Now().UTC().After(*repo.TwoFactorCodeExpiresAt) {
160
+
return helpers.ExpiredTokenError(e)
161
+
}
115
162
}
116
163
117
164
sess.Options = &sessions.Options{
···
127
174
return err
128
175
}
129
176
130
-
if req.QueryParams != "" {
131
-
return e.Redirect(303, "/oauth/authorize?"+req.QueryParams)
177
+
if queryParams != "" {
178
+
return e.Redirect(303, "/oauth/authorize"+queryParams)
132
179
} else {
133
180
return e.Redirect(303, "/account")
134
181
}
+54
-1
server/handle_server_create_session.go
+54
-1
server/handle_server_create_session.go
···
1
1
package server
2
2
3
3
import (
4
+
"context"
4
5
"errors"
6
+
"fmt"
5
7
"strings"
8
+
"time"
6
9
7
10
"github.com/Azure/go-autorest/autorest/to"
8
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
91
94
return helpers.InputError(e, to.StringPtr("InvalidRequest"))
92
95
}
93
96
97
+
// if repo requires 2FA token and one hasn't been provided, return error prompting for one
98
+
if repo.TwoFactorType != models.TwoFactorTypeNone && (req.AuthFactorToken == nil || *req.AuthFactorToken == "") {
99
+
err = s.createAndSendTwoFactorCode(ctx, repo)
100
+
if err != nil {
101
+
logger.Error("sending 2FA code", "error", err)
102
+
return helpers.ServerError(e, nil)
103
+
}
104
+
105
+
return helpers.InputError(e, to.StringPtr("AuthFactorTokenRequired"))
106
+
}
107
+
108
+
// if 2FA is required, now check that the one provided is valid
109
+
if repo.TwoFactorType != models.TwoFactorTypeNone {
110
+
if repo.TwoFactorCode == nil || repo.TwoFactorCodeExpiresAt == nil {
111
+
err = s.createAndSendTwoFactorCode(ctx, repo)
112
+
if err != nil {
113
+
logger.Error("sending 2FA code", "error", err)
114
+
return helpers.ServerError(e, nil)
115
+
}
116
+
117
+
return helpers.InputError(e, to.StringPtr("AuthFactorTokenRequired"))
118
+
}
119
+
120
+
if *repo.TwoFactorCode != *req.AuthFactorToken {
121
+
return helpers.InvalidTokenError(e)
122
+
}
123
+
124
+
if time.Now().UTC().After(*repo.TwoFactorCodeExpiresAt) {
125
+
return helpers.ExpiredTokenError(e)
126
+
}
127
+
}
128
+
94
129
sess, err := s.createSession(ctx, &repo.Repo)
95
130
if err != nil {
96
131
logger.Error("error creating session", "error", err)
···
104
139
Did: repo.Repo.Did,
105
140
Email: repo.Email,
106
141
EmailConfirmed: repo.EmailConfirmedAt != nil,
107
-
EmailAuthFactor: repo.EmailAuthFactor,
142
+
EmailAuthFactor: repo.TwoFactorType != models.TwoFactorTypeNone,
108
143
Active: repo.Active(),
109
144
Status: repo.Status(),
110
145
})
111
146
}
147
+
148
+
func (s *Server) createAndSendTwoFactorCode(ctx context.Context, repo models.RepoActor) error {
149
+
// TODO: when implementing a new type of 2FA there should be some logic in here to send the
150
+
// right type of code
151
+
152
+
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(5), helpers.RandomVarchar(5))
153
+
eat := time.Now().Add(10 * time.Minute).UTC()
154
+
155
+
if err := s.db.Exec(ctx, "UPDATE repos SET two_factor_code = ?, two_factor_code_expires_at = ? WHERE did = ?", nil, code, eat, repo.Repo.Did).Error; err != nil {
156
+
return fmt.Errorf("updating repo: %w", err)
157
+
}
158
+
159
+
if err := s.sendTwoFactorCode(repo.Email, repo.Handle, code); err != nil {
160
+
return fmt.Errorf("sending email: %w", err)
161
+
}
162
+
163
+
return nil
164
+
}
+1
-1
server/handle_server_get_session.go
+1
-1
server/handle_server_get_session.go
+8
-3
server/handle_server_update_email.go
+8
-3
server/handle_server_update_email.go
···
33
33
// To disable email auth factor a token is required.
34
34
// To enable email auth factor a token is not required.
35
35
// If updating an email address, a token will be sent anyway
36
-
if urepo.EmailAuthFactor && req.EmailAuthFactor == false && req.Token == "" {
36
+
if urepo.TwoFactorType != models.TwoFactorTypeNone && req.EmailAuthFactor == false && req.Token == "" {
37
37
return helpers.InvalidTokenError(e)
38
38
}
39
39
···
51
51
}
52
52
}
53
53
54
-
query := "UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, email_auth_factor = ?, email = ?"
54
+
twoFactorType := models.TwoFactorTypeNone
55
+
if req.EmailAuthFactor {
56
+
twoFactorType = models.TwoFactorTypeEmail
57
+
}
58
+
59
+
query := "UPDATE repos SET email_update_code = NULL, email_update_code_expires_at = NULL, two_factor_type = ?, email = ?"
55
60
56
61
if urepo.Email != req.Email {
57
62
query += ",email_confirmed_at = NULL"
···
59
64
60
65
query += " WHERE did = ?"
61
66
62
-
if err := s.db.Exec(ctx, query, nil, req.EmailAuthFactor, req.Email, urepo.Repo.Did).Error; err != nil {
67
+
if err := s.db.Exec(ctx, query, nil, twoFactorType, req.Email, urepo.Repo.Did).Error; err != nil {
63
68
logger.Error("error updating repo", "error", err)
64
69
return helpers.ServerError(e, nil)
65
70
}
+19
server/mail.go
+19
server/mail.go
···
96
96
97
97
return nil
98
98
}
99
+
100
+
func (s *Server) sendTwoFactorCode(email, handle, code string) error {
101
+
if s.mail == nil {
102
+
return nil
103
+
}
104
+
105
+
s.mailLk.Lock()
106
+
defer s.mailLk.Unlock()
107
+
108
+
s.mail.To(email)
109
+
s.mail.Subject("2FA code for " + s.config.Hostname)
110
+
s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your 2FA code is %s. This code will expire in ten minutes.", handle, code))
111
+
112
+
if err := s.mail.Send(); err != nil {
113
+
return err
114
+
}
115
+
116
+
return nil
117
+
}
+4
server/templates/signin.html
+4
server/templates/signin.html
···
26
26
type="password"
27
27
placeholder="Password"
28
28
/>
29
+
{{ if .flashes.tokenrequired }}
30
+
<br />
31
+
<input name="token" id="token" placeholder="Enter your 2FA token" />
32
+
{{ end }}
29
33
<input name="query_params" type="hidden" value="{{ .QueryParams }}" />
30
34
<button class="primary" type="submit" value="Login">Login</button>
31
35
</form>