Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ secrets.h
sketches/secrets.h

.env
.vscode
.vscode
.kiro
122 changes: 91 additions & 31 deletions backend/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/sha256"
"fmt"
"net/http"
"strings"
"time"

"github.com/gagan-devv/terradetect/backend/config"
Expand Down Expand Up @@ -32,7 +33,8 @@ func NewAuthHandler(database *db.Database, cfg *config.Config) *AuthHandler {
type registerRequest struct {
Username string `json:"username" binding:"required,min=3,max=32"`
Password string `json:"password" binding:"required,min=8"`
DeviceID string `json:"device_id" binding:"required,len=6"`
DeviceID string `json:"device_id"`
Email string `json:"email"`
}

func (h *AuthHandler) Register(c *gin.Context) {
Expand All @@ -48,19 +50,27 @@ func (h *AuthHandler) Register(c *gin.Context) {
defer cancel()

var device models.Device
err := h.db.Devices.FindOne(ctx, bson.M{
"device_id": req.DeviceID,
"registered": false,
}).Decode(&device)

if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"code": "DEVICE_NOT_REGISTERED",
"message": "Invalid or already registered device ID.",
},
})
return
deviceFound := false
if req.DeviceID != "" {
if !models.IsValidDeviceID(req.DeviceID) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": gin.H{"code": "VALIDATION_ERROR", "message": "device_id must be two uppercase letters followed by four digits (e.g. AB1234)"}})
return
}
err := h.db.Devices.FindOne(ctx, bson.M{
"device_id": req.DeviceID,
"registered": false,
}).Decode(&device)

if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"code": "DEVICE_NOT_REGISTERED",
"message": "Invalid or already registered device ID.",
},
})
return
}
deviceFound = true
}

hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
Expand All @@ -74,12 +84,17 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}

_, err = h.db.Users.InsertOne(ctx, bson.M{
userDoc := bson.M{
"username": req.Username,
"password_hash": string(hash),
"device_id": req.DeviceID,
"created_at": primitive.NewDateTimeFromTime(time.Now()),
})
}
if req.Email != "" {
userDoc["email"] = req.Email
}

_, err = h.db.Users.InsertOne(ctx, userDoc)

if err != nil {
c.JSON(http.StatusConflict, gin.H{
Expand All @@ -91,22 +106,29 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}

_, _ = h.db.Devices.UpdateOne(ctx,
bson.M{"device_id": req.DeviceID},
bson.M{"$set": bson.M{"registered": true}},
)
if deviceFound {
_, _ = h.db.Devices.UpdateOne(ctx,
bson.M{"device_id": req.DeviceID},
bson.M{"$set": bson.M{"registered": true}},
)
}

c.JSON(http.StatusCreated, gin.H{
resp := gin.H{
"username": req.Username,
"device_id": req.DeviceID,
"api_key": device.APIKey,
})
}
if deviceFound {
resp["api_key"] = device.APIKey
}

c.JSON(http.StatusCreated, resp)
}

type loginRequest struct {
Username string `json:"username" binding:"required"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password" binding:"required"`
DeviceID string `json:"device_id" binding:"required"`
DeviceID string `json:"device_id"`
}

func (h *AuthHandler) Login(c *gin.Context) {
Expand All @@ -121,25 +143,63 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}

// Require either username or email
if strings.TrimSpace(req.Username) == "" && strings.TrimSpace(req.Email) == "" {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{
"code": "VALIDATION_ERROR",
"message": "username or email is required",
},
})
return
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

var user models.User

err := h.db.Users.FindOne(ctx, bson.M{
"username": req.Username,
"device_id": req.DeviceID,
}).Decode(&user)
// Build query: match username OR email; include device_id if provided
filters := []bson.M{}
if strings.TrimSpace(req.Username) != "" {
filters = append(filters, bson.M{"username": req.Username})
}
if strings.TrimSpace(req.Email) != "" {
filters = append(filters, bson.M{"email": req.Email})
}

query := bson.M{"$or": filters}

err := h.db.Users.FindOne(ctx, query).Decode(&user)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "INVALID_CREDENTIALS",
"message": "Invalid username, password, or device ID.",
"message": "Invalid username/email, password, or device ID.",
},
})
return
}

// If a device_id was provided, verify it matches the user's device if the
// user already has a device associated. If the user has no device set,
// allow login (they can claim a device later).
if req.DeviceID != "" {
if !models.IsValidDeviceID(req.DeviceID) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": gin.H{"code": "VALIDATION_ERROR", "message": "device_id must be two uppercase letters followed by four digits (e.g. AB1234)"}})
return
}
if user.DeviceID != "" && req.DeviceID != user.DeviceID {
c.JSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "INVALID_CREDENTIALS",
"message": "Invalid username/email, password, or device ID.",
},
})
return
}
}

if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
Expand Down Expand Up @@ -230,7 +290,7 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
}

claims := token.Claims.(jwt.MapClaims)
if claims["token"] != "refresh" {
if claims["token_type"] != "refresh" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "UNAUTHORIZED",
Expand Down
7 changes: 7 additions & 0 deletions backend/handlers/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ func (h *DeviceHandler) Check(c *gin.Context) {
return
}

if !models.IsValidDeviceID(req.DeviceID) {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{"code": "VALIDATION_ERROR", "message": "device_id must be two uppercase letters followed by four digits (e.g. AB1234)"},
})
return
}

ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
defer cancel()

Expand Down
5 changes: 5 additions & 0 deletions backend/handlers/sensor.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ func (h *SensorHandler) ReceiveESP32(c *gin.Context) {
return
}

if !models.IsValidDeviceID(payload.DeviceId) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": gin.H{"code": "VALIDATION_ERROR", "message": "device_id must be two uppercase letters followed by four digits (e.g. AB1234)"}})
return
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

Expand Down
4 changes: 2 additions & 2 deletions backend/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func Auth(secretKey string) gin.HandlerFunc {
}

// Reject refresh tokens used as access tokens
if claims["type"] == "refresh" {
if claims["token_type"] == "refresh" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{"code": "UNAUTHORIZED", "message": "Use access token, not refresh token."},
})
Expand All @@ -59,4 +59,4 @@ func Auth(secretKey string) gin.HandlerFunc {
c.Set("device_id", claims["device_id"])
c.Next()
}
}
}
15 changes: 14 additions & 1 deletion backend/models/device.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
package models

import "go.mongodb.org/mongo-driver/bson/primitive"
import (
"regexp"

"go.mongodb.org/mongo-driver/bson/primitive"
)

type Device struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"_id"`
DeviceID string `bson:"device_id" json:"device_id"`
APIKey string `bson:"api_key" json:"api_key"`
Registered bool `bson:"registered" json:"registered"`
CreatedAt primitive.DateTime `bson:"created_at" json:"created_at"`
}

var deviceIDRegexp = regexp.MustCompile(`^[A-Z]{2}[0-9]{4}$`)

// IsValidDeviceID returns true when the device id matches the
// expected pattern: two uppercase letters followed by four digits
// (example: AB1234).
func IsValidDeviceID(id string) bool {
return deviceIDRegexp.MatchString(id)
}
3 changes: 2 additions & 1 deletion backend/models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import "go.mongodb.org/mongo-driver/bson/primitive"
type User struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"_id"`
Username string `bson:"username" json:"username"`
Email string `bson:"email,omitempty" json:"email,omitempty"`
PasswordHash string `bson:"password_hash" json:"-"`
DeviceID string `bson:"device_id" json:"device_id"`
CreatedAt primitive.DateTime `bson:"created_at" json:"created_at"`
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"name":"Local: seed_db","url":"/home/gagan-ahlawat/Documents/TerraDetect/backend/scripts/seed_db.py","tests":[{"id":1774633948944,"input":"","output":""}],"interactive":false,"memoryLimit":1024,"timeLimit":3000,"srcPath":"/home/gagan-ahlawat/Documents/TerraDetect/backend/scripts/seed_db.py","group":"local","local":true}
Loading
Loading