Verify Your Identity
++ Enter the 6-digit code sent to your registered device to complete the login process. +
+diff --git a/cache/cache.go b/cache/cache.go
index 81ab8e8..d859b71 100644
--- a/cache/cache.go
+++ b/cache/cache.go
@@ -15,6 +15,7 @@ type UserWithExpired struct {
Username string
Email string
Password string
+ Totp string
AccessAt time.Time
mu sync.Mutex
}
@@ -103,6 +104,7 @@ func GetUser(email string) (*UserWithExpired, error) {
Username: userData.Username,
Email: userData.Email,
Password: userData.Password,
+ Totp: userData.Totp,
AccessAt: time.Now(),
}
diff --git a/db/database.go b/db/database.go
index 319b0fb..668b017 100644
--- a/db/database.go
+++ b/db/database.go
@@ -45,6 +45,8 @@ type Database interface {
UpdateUploadedByte(index int64, fileID string)
UpdateUploadedChunk(index int64, fileID string)
FinalizeFileUpload(fileID string)
+
+ InitializeTotp(email string, secret string) error
}
func NewMYSQLdb(username, password, host, port, dbName string) Database {
@@ -262,6 +264,20 @@ func (db *mySQLdb) FinalizeFileUpload(fileID string) {
db.Save(&file)
}
+func (db *mySQLdb) InitializeTotp(email string, secret string) error {
+ var user models.User
+ err := db.DB.Table("users").Where("email = ?", email).First(&user).Error
+ if err != nil {
+ return err
+ }
+ user.Totp = secret
+ err = db.Save(&user).Error
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
// POSTGRES FUNCTION
func (db *postgresDB) IsUserRegistered(email string, username string) bool {
var data models.User
@@ -369,3 +385,17 @@ func (db *postgresDB) FinalizeFileUpload(fileID string) {
file.Done = true
db.Save(&file)
}
+
+func (db *postgresDB) InitializeTotp(email string, secret string) error {
+ var user models.User
+ err := db.DB.Table("users").Where("email = $1", email).First(&user).Error
+ if err != nil {
+ return err
+ }
+ user.Totp = secret
+ err = db.Save(&user).Error
+ if err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/go.mod b/go.mod
index 683d919..b967285 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,9 @@ require (
github.com/a-h/templ v0.2.648
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
+ github.com/mdp/qrterminal/v3 v3.2.0
+ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
+ github.com/xlzd/gotp v0.1.0
golang.org/x/crypto v0.21.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/mysql v1.5.6
@@ -20,6 +23,9 @@ require (
github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
+ golang.org/x/sys v0.18.0 // indirect
+ golang.org/x/term v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
+ rsc.io/qr v0.2.0 // indirect
)
diff --git a/go.sum b/go.sum
index 3f53935..480b361 100644
--- a/go.sum
+++ b/go.sum
@@ -21,15 +21,25 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk=
+github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
+github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
+golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
+golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
+golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
@@ -47,3 +57,5 @@ gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4c
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.8 h1:WAGEZ/aEcznN4D03laj8DKnehe1e9gYQAjW8xyPRdeo=
gorm.io/gorm v1.25.8/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
+rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
+rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
diff --git a/handler/auth/totp/totp.go b/handler/auth/totp/totp.go
new file mode 100644
index 0000000..e095fb5
--- /dev/null
+++ b/handler/auth/totp/totp.go
@@ -0,0 +1,139 @@
+package totpHandler
+
+import (
+ "errors"
+ "fmt"
+ "github.com/fossyy/filekeeper/session"
+ "github.com/fossyy/filekeeper/types"
+ "github.com/fossyy/filekeeper/utils"
+ totpView "github.com/fossyy/filekeeper/view/totp"
+ "github.com/xlzd/gotp"
+ "net/http"
+ "strings"
+ "time"
+)
+
+func GET(w http.ResponseWriter, r *http.Request) {
+ _, user, _ := session.GetSession(r)
+ if user.Authenticated || user.Totp == "" {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ component := totpView.Main("Filekeeper - 2FA Page")
+ err := component.Render(r.Context(), w)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ return
+}
+
+func POST(w http.ResponseWriter, r *http.Request) {
+ r.ParseForm()
+ code := r.Form.Get("code")
+ _, user, key := session.GetSession(r)
+ totp := gotp.NewDefaultTOTP(user.Totp)
+
+ if totp.Verify(code, time.Now().Unix()) {
+ storeSession, err := session.Get(key)
+ if err != nil {
+ return
+ }
+ storeSession.Values["user"] = types.User{
+ UserID: user.UserID,
+ Email: user.Email,
+ Username: user.Username,
+ Authenticated: true,
+ }
+ userAgent := r.Header.Get("User-Agent")
+ browserInfo, osInfo := ParseUserAgent(userAgent)
+
+ sessionInfo := session.SessionInfo{
+ SessionID: storeSession.ID,
+ Browser: browserInfo["browser"],
+ Version: browserInfo["version"],
+ OS: osInfo["os"],
+ OSVersion: osInfo["version"],
+ IP: utils.ClientIP(r),
+ Location: "Indonesia",
+ }
+
+ storeSession.Save(w)
+ session.AddSessionInfo(user.Email, &sessionInfo)
+
+ cookie, err := r.Cookie("redirect")
+ if errors.Is(err, http.ErrNoCookie) {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: "redirect",
+ MaxAge: -1,
+ })
+ http.Redirect(w, r, cookie.Value, http.StatusSeeOther)
+ return
+ } else {
+ fmt.Fprint(w, "wrong")
+ }
+}
+
+func ParseUserAgent(userAgent string) (map[string]string, map[string]string) {
+ browserInfo := make(map[string]string)
+ osInfo := make(map[string]string)
+ if strings.Contains(userAgent, "Firefox") {
+ browserInfo["browser"] = "Firefox"
+ parts := strings.Split(userAgent, "Firefox/")
+ if len(parts) > 1 {
+ version := strings.Split(parts[1], " ")[0]
+ browserInfo["version"] = version
+ }
+ } else if strings.Contains(userAgent, "Chrome") {
+ browserInfo["browser"] = "Chrome"
+ parts := strings.Split(userAgent, "Chrome/")
+ if len(parts) > 1 {
+ version := strings.Split(parts[1], " ")[0]
+ browserInfo["version"] = version
+ }
+ } else {
+ browserInfo["browser"] = "Unknown"
+ browserInfo["version"] = "Unknown"
+ }
+
+ if strings.Contains(userAgent, "Windows") {
+ osInfo["os"] = "Windows"
+ parts := strings.Split(userAgent, "Windows ")
+ if len(parts) > 1 {
+ version := strings.Split(parts[1], ";")[0]
+ osInfo["version"] = version
+ }
+ } else if strings.Contains(userAgent, "Macintosh") {
+ osInfo["os"] = "Mac OS"
+ parts := strings.Split(userAgent, "Mac OS X ")
+ if len(parts) > 1 {
+ version := strings.Split(parts[1], ";")[0]
+ osInfo["version"] = version
+ }
+ } else if strings.Contains(userAgent, "Linux") {
+ osInfo["os"] = "Linux"
+ osInfo["version"] = "Unknown"
+ } else if strings.Contains(userAgent, "Android") {
+ osInfo["os"] = "Android"
+ parts := strings.Split(userAgent, "Android ")
+ if len(parts) > 1 {
+ version := strings.Split(parts[1], ";")[0]
+ osInfo["version"] = version
+ }
+ } else if strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "iPad") || strings.Contains(userAgent, "iPod") {
+ osInfo["os"] = "iOS"
+ parts := strings.Split(userAgent, "OS ")
+ if len(parts) > 1 {
+ version := strings.Split(parts[1], " ")[0]
+ osInfo["version"] = version
+ }
+ } else {
+ osInfo["os"] = "Unknown"
+ osInfo["version"] = "Unknown"
+ }
+
+ return browserInfo, osInfo
+}
diff --git a/handler/signin/signin.go b/handler/signin/signin.go
index d779153..edcde52 100644
--- a/handler/signin/signin.go
+++ b/handler/signin/signin.go
@@ -4,19 +4,22 @@ import (
"errors"
"github.com/a-h/templ"
"github.com/fossyy/filekeeper/cache"
- "net/http"
- "strings"
-
"github.com/fossyy/filekeeper/logger"
"github.com/fossyy/filekeeper/session"
"github.com/fossyy/filekeeper/types"
"github.com/fossyy/filekeeper/utils"
signinView "github.com/fossyy/filekeeper/view/signin"
+ "net/http"
+ "strings"
)
var log *logger.AggregatedLogger
var errorMessages = make(map[string]string)
+func init() {
+
+}
+
func init() {
errorMessages = map[string]string{
"redirect_uri_mismatch": "The redirect URI provided does not match the one registered with our service. Please contact the administrator for assistance.",
@@ -92,6 +95,20 @@ func POST(w http.ResponseWriter, r *http.Request) {
}
if email == userData.Email && utils.CheckPasswordHash(password, userData.Password) {
+ if userData.Totp != "" {
+ storeSession := session.Create()
+ storeSession.Values["user"] = types.User{
+ UserID: userData.UserID,
+ Email: email,
+ Username: userData.Username,
+ Totp: userData.Totp,
+ Authenticated: false,
+ }
+ storeSession.Save(w)
+ http.Redirect(w, r, "/auth/totp", http.StatusSeeOther)
+ return
+ }
+
storeSession := session.Create()
storeSession.Values["user"] = types.User{
UserID: userData.UserID,
diff --git a/handler/user/totp/setup.go b/handler/user/totp/setup.go
new file mode 100644
index 0000000..065d8c6
--- /dev/null
+++ b/handler/user/totp/setup.go
@@ -0,0 +1,86 @@
+package userHandlerTotpSetup
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "github.com/fossyy/filekeeper/cache"
+ userTotpSetupView "github.com/fossyy/filekeeper/view/user/totp"
+ "image/png"
+ "net/http"
+ "time"
+
+ "github.com/fossyy/filekeeper/db"
+ "github.com/fossyy/filekeeper/types"
+ "github.com/skip2/go-qrcode"
+ "github.com/xlzd/gotp"
+)
+
+func generateQRCode(uri string) (string, error) {
+ qr, err := qrcode.New(uri, qrcode.Medium)
+ if err != nil {
+ return "", fmt.Errorf("failed to generate QR code: %w", err)
+ }
+
+ var buffer bytes.Buffer
+ if err := png.Encode(&buffer, qr.Image(256)); err != nil {
+ return "", fmt.Errorf("failed to encode QR code to PNG: %w", err)
+ }
+
+ return base64.StdEncoding.EncodeToString(buffer.Bytes()), nil
+}
+
+func GET(w http.ResponseWriter, r *http.Request) {
+ secret := gotp.RandomSecret(16)
+ userSession := r.Context().Value("user").(types.User)
+ totp := gotp.NewDefaultTOTP(secret)
+ uri := totp.ProvisioningUri(userSession.Email, "filekeeper")
+ base64Str, err := generateQRCode(uri)
+ if err != nil {
+ fmt.Printf("%v\n", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ component := userTotpSetupView.Main("Filekeeper - 2FA Setup Page", base64Str, secret, userSession)
+ if err := component.Render(r.Context(), w); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+}
+
+func POST(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ code := r.Form.Get("totp")
+ secret := r.Form.Get("secret")
+ totp := gotp.NewDefaultTOTP(secret)
+ userSession := r.Context().Value("user").(types.User)
+ if totp.Verify(code, time.Now().Unix()) {
+ if err := db.DB.InitializeTotp(userSession.Email, secret); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ cache.DeleteUser(userSession.Email)
+ fmt.Fprint(w, "Authentication successful! Access granted.")
+ return
+ } else {
+ uri := totp.ProvisioningUri(userSession.Email, "filekeeper")
+
+ base64Str, err := generateQRCode(uri)
+ if err != nil {
+ fmt.Printf("%v\n", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ component := userTotpSetupView.Main("Filekeeper - 2FA Setup Page", base64Str, secret, userSession)
+ if err := component.Render(r.Context(), w); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ }
+}
diff --git a/routes/routes.go b/routes/routes.go
index 51c46df..8948521 100644
--- a/routes/routes.go
+++ b/routes/routes.go
@@ -4,6 +4,7 @@ import (
googleOauthHandler "github.com/fossyy/filekeeper/handler/auth/google"
googleOauthCallbackHandler "github.com/fossyy/filekeeper/handler/auth/google/callback"
googleOauthSetupHandler "github.com/fossyy/filekeeper/handler/auth/google/setup"
+ totpHandler "github.com/fossyy/filekeeper/handler/auth/totp"
downloadHandler "github.com/fossyy/filekeeper/handler/download"
downloadFileHandler "github.com/fossyy/filekeeper/handler/download/file"
forgotPasswordHandler "github.com/fossyy/filekeeper/handler/forgotPassword"
@@ -16,6 +17,7 @@ import (
uploadHandler "github.com/fossyy/filekeeper/handler/upload"
"github.com/fossyy/filekeeper/handler/upload/initialisation"
userHandler "github.com/fossyy/filekeeper/handler/user"
+ userHandlerTotpSetup "github.com/fossyy/filekeeper/handler/user/totp"
"github.com/fossyy/filekeeper/middleware"
"net/http"
)
@@ -46,7 +48,17 @@ func SetupRoutes() *http.ServeMux {
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
+ })
+ authRouter.HandleFunc("/totp", func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ middleware.Guest(totpHandler.GET, w, r)
+ case http.MethodPost:
+ middleware.Guest(totpHandler.POST, w, r)
+ default:
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ }
})
authRouter.HandleFunc("/google/callback", func(w http.ResponseWriter, r *http.Request) {
@@ -129,6 +141,17 @@ func SetupRoutes() *http.ServeMux {
}
})
+ handler.HandleFunc("/user/totp/setup", func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ middleware.Auth(userHandlerTotpSetup.GET, w, r)
+ case http.MethodPost:
+ middleware.Auth(userHandlerTotpSetup.POST, w, r)
+ default:
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ }
+ })
+
// Upload router
uploadRouter := http.NewServeMux()
handler.Handle("/upload/", http.StripPrefix("/upload", uploadRouter))
diff --git a/schema.sql b/schema.sql
index 6a4b640..d705a30 100644
--- a/schema.sql
+++ b/schema.sql
@@ -1,2 +1,2 @@
-CREATE TABLE IF NOT EXISTS users (user_id VARCHAR(255) PRIMARY KEY NOT NULL,username VARCHAR(255) UNIQUE NOT NULL,email VARCHAR(255) UNIQUE NOT NULL,password TEXT NOT NULL);
+CREATE TABLE IF NOT EXISTS users (user_id VARCHAR(255) PRIMARY KEY NOT NULL,username VARCHAR(255) UNIQUE NOT NULL,email VARCHAR(255) UNIQUE NOT NULL,password TEXT NOT NULL,totp VARCHAR(255) DEFAULT NULL);
CREATE TABLE IF NOT EXISTS files (id VARCHAR(255) PRIMARY KEY NOT NULL,owner_id VARCHAR(255) NOT NULL,name TEXT NOT NULL,size BIGINT NOT NULL,downloaded BIGINT NOT NULL,uploaded_byte BIGINT NOT NULL DEFAULT 0, uploaded_chunk BIGINT NOT NULL DEFAULT -1,done BOOLEAN NOT NULL DEFAULT FALSE,FOREIGN KEY (owner_id) REFERENCES users(user_id));
\ No newline at end of file
diff --git a/session/session.go b/session/session.go
index 0a1ed1e..6f18872 100644
--- a/session/session.go
+++ b/session/session.go
@@ -176,6 +176,14 @@ func GetSession(r *http.Request) (UserStatus, types.User, string) {
return Unauthorized, types.User{}, ""
}
+ if !userSession.Authenticated && userSession.Totp != "" {
+ return Unauthorized, userSession, cookie.Value
+ }
+
+ if !userSession.Authenticated {
+ return Unauthorized, types.User{}, ""
+ }
+
return Authorized, userSession, cookie.Value
}
diff --git a/types/models/models.go b/types/models/models.go
index 50743bf..804a953 100644
--- a/types/models/models.go
+++ b/types/models/models.go
@@ -7,6 +7,7 @@ type User struct {
Username string `gorm:"unique;not null"`
Email string `gorm:"unique;not null"`
Password string `gorm:"not null"`
+ Totp string `gorm:"not null"`
}
type File struct {
diff --git a/types/types.go b/types/types.go
index 8c5c410..7eda14f 100644
--- a/types/types.go
+++ b/types/types.go
@@ -13,6 +13,7 @@ type User struct {
UserID uuid.UUID
Email string
Username string
+ Totp string
Authenticated bool
}
diff --git a/view/totp/totp.templ b/view/totp/totp.templ
new file mode 100644
index 0000000..3694d1c
--- /dev/null
+++ b/view/totp/totp.templ
@@ -0,0 +1,54 @@
+package totpView
+
+import (
+ "github.com/fossyy/filekeeper/view/layout"
+)
+
+templ content(title string) {
+ @layout.Base(title){
+
+ Enter the 6-digit code sent to your registered device to complete the login process.
+ Secure your account with time-based one-time passwords (TOTP). Here's how to set up the Google Authenticator app: Backup Code: TOTP Secret:Verify Your Identity
+ Set up Two-Factor Authentication
+
+
+
+