yoake/internal/auth/auth.go
2022-11-11 17:16:53 -05:00

221 lines
6.1 KiB
Go

package auth
import (
"crypto/subtle"
"fmt"
"log"
"net/http"
"time"
"github.com/alexedwards/argon2id"
"github.com/eternal-flame-AD/yoake/config"
"github.com/gorilla/sessions"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
const AuthSessionName = "auth_session"
type RequestAuth struct {
Present bool
Valid bool
Roles []string
Expire time.Time
}
func (a RequestAuth) HasRole(role Role) bool {
if !a.Valid {
return false
}
for _, r := range a.Roles {
if r == string(role) {
return true
}
}
return false
}
type RoleInsufficientError struct {
RoleRequired Role
RolesAvailable []string
}
func (e RoleInsufficientError) Error() string {
return fmt.Sprintf("role insufficient: required %v, you have %v", e.RoleRequired, e.RolesAvailable)
}
func (e RoleInsufficientError) Code() int {
if len(e.RolesAvailable) == 0 {
return http.StatusUnauthorized
}
return http.StatusForbidden
}
func (a RequestAuth) RequireRole(role Role) error {
if config := config.Config(); config.Auth.DevMode.GrantAll && !config.Listen.Ssl.Use {
log.Printf("dev mode: role %v granted without checking", role)
return nil
}
if a.HasRole(role) {
return nil
}
return RoleInsufficientError{RoleRequired: role, RolesAvailable: a.Roles}
}
func RequireMiddleware(role Role) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
auth := GetRequestAuth(c)
if err := auth.RequireRole(role); err != nil {
return err
}
return next(c)
}
}
}
func Middleware(store sessions.Store) echo.MiddlewareFunc {
yubiAuthLazyInit()
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
sess, _ := store.Get(c.Request(), AuthSessionName)
sess.Options = &sessions.Options{
Path: "/",
HttpOnly: true,
}
var auth RequestAuth
if expireTs, ok := sess.Values["expire"].(string); ok {
auth.Present = true
if expireTime, err := time.Parse(time.RFC3339, expireTs); err != nil {
log.Printf("invalid expireTime: %v", expireTs)
} else {
auth.Expire = expireTime
if time.Now().Before(expireTime) {
auth.Valid = true
}
}
}
if existingRoles, ok := sess.Values["roles"].([]string); !ok {
sess.Values["roles"] = []string{}
sess.Save(c.Request(), c.Response())
} else if auth.Valid {
auth.Roles = existingRoles
}
c.Set("auth_"+AuthSessionName, auth)
c.Set("auth_store", store)
return next(c)
}
}
}
func issueSession(c echo.Context, period time.Duration, roles []string) error {
sess, _ := c.Get("auth_store").(sessions.Store).Get(c.Request(), AuthSessionName)
sess.Options = &sessions.Options{
Path: "/",
HttpOnly: true,
}
if period == 0 {
period = time.Duration(config.Config().Auth.ValidMinutes) * time.Minute
}
if period < 0 {
sess.Values["expire"] = (time.Time{}).Format(time.RFC3339)
sess.Values["roles"] = ""
} else {
sess.Values["expire"] = time.Now().Add(period).Format(time.RFC3339)
sess.Values["roles"] = roles
log.Printf("Issued session for %v, roles: %v", period, roles)
}
return sess.Save(c.Request(), c.Response())
}
type LoginForm struct {
Username string `json:"username" form:"username"`
Password string `json:"password" form:"password"`
OtpResponse string `json:"otp_response" form:"otp_response"`
}
func Register(g *echo.Group) (err error) {
g.GET("/auth.json", func(c echo.Context) error {
return c.JSON(http.StatusOK, GetRequestAuth(c))
})
loginRateLimiterStore := middleware.NewRateLimiterMemoryStoreWithConfig(middleware.RateLimiterMemoryStoreConfig{
ExpiresIn: 300 * time.Second,
Rate: 2,
Burst: 4,
})
loginRateLimiter := middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{
Store: loginRateLimiterStore,
})
g.POST("/login", func(c echo.Context) error {
var form LoginForm
if err := c.Bind(&form); err != nil {
return err
}
var verifiedOtpPubId string
if form.OtpResponse != "" {
if yubiAuth == nil {
return echo.NewHTTPError(http.StatusNotImplemented, "Yubikey authentication not configured")
}
if yr, ok, err := yubiAuth.Verify(form.OtpResponse); err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed: "+err.Error())
} else if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Yubikey authentication failed")
} else {
// sessionUseCounter := yr.GetResultParameter("sessionuse")
// sessionCounter := yr.GetResultParameter("sessioncounter")
keyPublicId := yr.GetResultParameter("otp")[:12]
verifiedOtpPubId = keyPublicId
}
}
if form.Username == "" {
return echo.NewHTTPError(http.StatusBadRequest, "username required")
}
if form.Password == "" {
return echo.NewHTTPError(http.StatusBadRequest, "password required")
}
if user, ok := config.Config().Auth.Users[form.Username]; ok {
if len(user.PublicKeyId) > 0 {
if verifiedOtpPubId == "" {
return echo.NewHTTPError(http.StatusUnauthorized, "otp required")
}
found := 0
for _, pubId := range user.PublicKeyId {
found += subtle.ConstantTimeCompare([]byte(pubId[:12]), []byte(verifiedOtpPubId))
}
if found == 0 {
return echo.NewHTTPError(http.StatusUnauthorized, "incorrect key used")
}
} else if verifiedOtpPubId != "" {
return echo.NewHTTPError(http.StatusBadRequest, "otp not required but you provided one, this may be an configuration error")
}
if match, _ := argon2id.ComparePasswordAndHash(form.Password, user.Password); match {
issueSession(c, 0, user.Roles)
c.JSON(http.StatusOK, map[string]interface{}{"message": "ok", "ok": true})
return nil
} else {
return echo.NewHTTPError(http.StatusUnauthorized, "incorrect password")
}
}
return echo.NewHTTPError(http.StatusUnauthorized, "invalid username")
}, loginRateLimiter)
g.DELETE("/login", func(c echo.Context) error {
return issueSession(c, -1, nil)
})
return nil
}
func GetRequestAuth(c echo.Context) RequestAuth {
if a, ok := c.Get("auth_" + AuthSessionName).(RequestAuth); ok {
return a
} else {
return RequestAuth{Present: false, Valid: false}
}
}