Skip to content

Commit 3a2a5de

Browse files
committed
feat(auth) email verification
1 parent 7796fb7 commit 3a2a5de

File tree

5 files changed

+139
-14
lines changed

5 files changed

+139
-14
lines changed

api/auth.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ func verifySession(w http.ResponseWriter, r *http.Request) {
160160

161161
switch session.VerificationMethod {
162162
case db.SessionVerificationEmail:
163-
verifySessionByEmail(w, r)
163+
verifySessionByEmail(session, w, r)
164164
return
165165

166166
case db.SessionVerificationTotp:
@@ -235,7 +235,14 @@ func authenticationHandler(w http.ResponseWriter, r *http.Request) (ok bool, req
235235
}
236236

237237
if !session.IsVerified() {
238-
helpers.WriteErrorStatus(w, "TOTP_REQUIRED", http.StatusUnauthorized)
238+
switch session.VerificationMethod {
239+
case db.SessionVerificationEmail:
240+
helpers.WriteErrorStatus(w, "EMAIL_OTP_REQUIRED", http.StatusUnauthorized)
241+
case db.SessionVerificationTotp:
242+
helpers.WriteErrorStatus(w, "TOTP_REQUIRED", http.StatusUnauthorized)
243+
default:
244+
helpers.WriteErrorStatus(w, "SESSION_NOT_VERIFIED", http.StatusUnauthorized)
245+
}
239246
return
240247
}
241248

api/auth_verify.go

Lines changed: 117 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,127 @@
1-
//go:build !pro
2-
31
package api
42

53
import (
4+
"bytes"
5+
"embed"
66
"net/http"
7+
"text/template"
8+
9+
"github.com/semaphoreui/semaphore/api/helpers"
10+
"github.com/semaphoreui/semaphore/db"
11+
"github.com/semaphoreui/semaphore/util"
12+
"github.com/semaphoreui/semaphore/util/mailer"
713
)
814

9-
func verifySessionByEmail(w http.ResponseWriter, r *http.Request) {
10-
w.WriteHeader(http.StatusBadRequest)
11-
return
15+
//go:embed templates/*.tmpl
16+
var templates embed.FS
17+
18+
type emailOtpRequestBody struct {
19+
Passcode string `json:"passcode"`
20+
}
21+
22+
func verifySessionByEmail(session *db.Session, w http.ResponseWriter, r *http.Request) {
23+
if !util.Config.Auth.Email.Enabled {
24+
helpers.WriteErrorStatus(w, "EMAIL_OTP_DISABLED", http.StatusForbidden)
25+
return
26+
}
27+
28+
var body emailOtpRequestBody
29+
if !helpers.Bind(w, r, &body) {
30+
w.WriteHeader(http.StatusBadRequest)
31+
return
32+
}
33+
34+
user, err := helpers.Store(r).GetUser(session.UserID)
35+
if err != nil {
36+
w.WriteHeader(http.StatusInternalServerError)
37+
return
38+
}
39+
40+
if user.EmailOtp == nil {
41+
helpers.WriteErrorStatus(w, "Cannot retrieve verification code from the server.", http.StatusInternalServerError)
42+
w.WriteHeader(http.StatusInternalServerError)
43+
return
44+
}
45+
46+
if user.EmailOtp.Code != body.Passcode {
47+
helpers.WriteErrorStatus(w, "Invalid verification code.", http.StatusUnauthorized)
48+
return
49+
}
50+
51+
if user.EmailOtp.IsExpired() {
52+
helpers.WriteErrorStatus(w, "The verification code has expired.", http.StatusUnauthorized)
53+
return
54+
}
55+
56+
err = helpers.Store(r).VerifySession(session.UserID, session.ID)
57+
if err != nil {
58+
helpers.WriteError(w, err)
59+
return
60+
}
61+
}
62+
63+
func sendEmailVerificationCode(code string, email string) error {
64+
body := bytes.NewBufferString("")
65+
var alert struct {
66+
Code string
67+
}
68+
69+
alert.Code = code
70+
71+
tpl, err := template.ParseFS(templates, "templates/email_otp_code.tmpl")
72+
73+
if err != nil {
74+
return err
75+
}
76+
77+
err = tpl.Execute(body, alert)
78+
79+
if err != nil {
80+
return err
81+
}
82+
83+
err = mailer.Send(
84+
util.Config.EmailSecure,
85+
util.Config.EmailTls,
86+
util.Config.EmailHost,
87+
util.Config.EmailPort,
88+
util.Config.EmailUsername,
89+
util.Config.EmailPassword,
90+
util.Config.EmailSender,
91+
email,
92+
"Email Verification Code",
93+
body.String(),
94+
)
95+
96+
return err
1297
}
1398

1499
func startEmailVerification(w http.ResponseWriter, r *http.Request) {
15-
w.WriteHeader(http.StatusBadRequest)
16-
return
100+
if !util.Config.Auth.Email.Enabled {
101+
helpers.WriteErrorStatus(w, "EMAIL_VERIFICATION_DISABLED", http.StatusForbidden)
102+
return
103+
}
104+
105+
session, ok := getSession(r)
106+
107+
if !ok {
108+
w.WriteHeader(http.StatusUnauthorized)
109+
return
110+
}
111+
112+
store := helpers.Store(r)
113+
114+
user, err := store.GetUser(session.UserID)
115+
if err != nil {
116+
helpers.WriteError(w, err)
117+
return
118+
}
119+
120+
code := util.RandString(16)
121+
122+
err = sendEmailVerificationCode(code, user.Email)
123+
if err != nil {
124+
helpers.WriteError(w, err)
125+
return
126+
}
17127
}

api/router.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@ import (
44
"bytes"
55
"embed"
66
"fmt"
7-
proApi "github.com/semaphoreui/semaphore/pro/api"
8-
proFeatures "github.com/semaphoreui/semaphore/pro/pkg/features"
9-
"github.com/semaphoreui/semaphore/services/server"
10-
task2 "github.com/semaphoreui/semaphore/services/tasks"
117
"net/http"
128
"os"
139
"path"
1410
"strings"
1511
"time"
1612

13+
proApi "github.com/semaphoreui/semaphore/pro/api"
14+
proFeatures "github.com/semaphoreui/semaphore/pro/pkg/features"
15+
"github.com/semaphoreui/semaphore/services/server"
16+
task2 "github.com/semaphoreui/semaphore/services/tasks"
17+
1718
"github.com/semaphoreui/semaphore/api/debug"
1819
"github.com/semaphoreui/semaphore/pkg/tz"
1920
proSubscriptions "github.com/semaphoreui/semaphore/pro/api/subscriptions"

api/templates/email_otp_code.tmpl

Whitespace-only changes.

db/User.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package db
22

33
import (
4+
"github.com/semaphoreui/semaphore/pkg/tz"
45
"time"
56
)
67

@@ -17,7 +18,8 @@ type User struct {
1718
Alert bool `db:"alert" json:"alert"`
1819
Pro bool `db:"pro" json:"pro"`
1920

20-
Totp *UserTotp `db:"-" json:"totp,omitempty"`
21+
Totp *UserTotp `db:"-" json:"totp,omitempty"`
22+
EmailOtp *UserEmailOtp `db:"-" json:"email_otp,omitempty"`
2123
}
2224

2325
type UserTotp struct {
@@ -59,3 +61,8 @@ func ValidateUser(user User) error {
5961
}
6062
return nil
6163
}
64+
65+
func (o *UserEmailOtp) IsExpired() bool {
66+
// Email OTP is valid for 10 minutes
67+
return tz.Now().Sub(o.Created) > 10*time.Minute
68+
}

0 commit comments

Comments
 (0)