feat: allow or block an ip/range of ips using labels

This commit is contained in:
Stavros
2025-06-25 17:18:52 +03:00
parent f3ec4baf3c
commit fae5e7919a
8 changed files with 129 additions and 3 deletions

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",

View File

@@ -42,6 +42,7 @@
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again",
"untrustedRedirectTitle": "Untrusted redirect",
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",

View File

@@ -17,9 +17,10 @@ export const UnauthorizedPage = () => {
const username = searchParams.get("username");
const resource = searchParams.get("resource");
const groupErr = searchParams.get("groupErr");
const ip = searchParams.get("ip");
if (!username) {
return <Navigate to="/" />;
if (!username && !ip) {
return <Navigate to="/login" />;
}
const { t } = useTranslation();
@@ -41,6 +42,10 @@ export const UnauthorizedPage = () => {
i18nKey = "unauthorizedGroupsSubtitle";
}
if (ip) {
i18nKey = "unauthorizedIpSubtitle";
}
return (
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
@@ -55,6 +60,7 @@ export const UnauthorizedPage = () => {
values={{
username,
resource,
ip,
}}
/>
</CardDescription>

View File

@@ -351,3 +351,44 @@ func (auth *Auth) GetBasicAuth(c *gin.Context) *types.User {
Password: password,
}
}
func (auth *Auth) CheckIP(c *gin.Context, labels types.Labels) bool {
// Get the IP address from the request
ip := c.ClientIP()
// Check if the IP is in block list
for _, blocked := range labels.IP.Block {
res, err := utils.FilterIP(blocked, ip)
if err != nil {
log.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue
}
if res {
log.Warn().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access")
return false
}
}
// For every IP in the allow list, check if the IP matches
for _, allowed := range labels.IP.Allow {
res, err := utils.FilterIP(allowed, ip)
if err != nil {
log.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue
}
if res {
log.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access")
return true
}
}
// If not in allowed range and allowed range is not empty, deny access
if len(labels.IP.Allow) > 0 {
log.Warn().Str("ip", ip).Msg("IP not in allow list, denying access")
return false
}
log.Debug().Str("ip", ip).Msg("IP not in allow or block list, allowing by default")
return true
}

View File

@@ -96,6 +96,38 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
return
}
// Check if the IP is allowed/blocked
ip := c.ClientIP()
if !h.Auth.CheckIP(c, labels) {
log.Warn().Str("ip", ip).Msg("IP not allowed")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(403, gin.H{
"status": 403,
"message": "Forbidden",
})
return
}
values := types.UnauthorizedQuery{
Resource: strings.Split(host, ".")[0],
IP: ip,
}
// Build query
queries, err := query.Values(values)
// Handle error
if err != nil {
log.Error().Err(err).Msg("Failed to build queries")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", h.Config.AppURL))
return
}
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
return
}
// Check if auth is enabled
authEnabled, err := h.Auth.AuthEnabled(c, labels)

View File

@@ -21,6 +21,7 @@ type UnauthorizedQuery struct {
Username string `url:"username"`
Resource string `url:"resource"`
GroupErr bool `url:"groupErr"`
IP string `url:"ip"`
}
// Proxy is the uri parameters for the proxy endpoint

View File

@@ -105,6 +105,12 @@ type BasicLabels struct {
Password string
}
// IP labels for a tinyauth protected container
type IPLabels struct {
Allow []string
Block []string
}
// Labels is a struct that contains the labels for a tinyauth protected container
type Labels struct {
Users string
@@ -113,4 +119,5 @@ type Labels struct {
Domain string
Basic BasicLabels
OAuth OAuthLabels
IP IPLabels
}

View File

@@ -3,6 +3,7 @@ package utils
import (
"encoding/base64"
"errors"
"net"
"net/url"
"os"
"regexp"
@@ -202,7 +203,7 @@ func GetLabels(labels map[string]string) (types.Labels, error) {
var labelsParsed types.Labels
// Decode the labels into the labels struct
err := parser.Decode(labels, &labelsParsed, "tinyauth", "tinyauth.users", "tinyauth.allowed", "tinyauth.headers", "tinyauth.domain", "tinyauth.basic", "tinyauth.oauth")
err := parser.Decode(labels, &labelsParsed, "tinyauth", "tinyauth.users", "tinyauth.allowed", "tinyauth.headers", "tinyauth.domain", "tinyauth.basic", "tinyauth.oauth", "tinyauth.ip")
// Check if there was an error
if err != nil {
@@ -368,3 +369,39 @@ func GetBasicAuth(username string, password string) string {
// Encode the auth string to base64
return base64.StdEncoding.EncodeToString([]byte(auth))
}
// Check if an IP is contained in a CIDR range/matches a single IP
func FilterIP(filter string, ip string) (bool, error) {
// Convert the check IP to an IP instance
ipAddr := net.ParseIP(ip)
// Check if the filter is a CIDR range
if strings.Contains(filter, "/") {
// Parse the CIDR range
_, cidr, err := net.ParseCIDR(filter)
// Check if there was an error
if err != nil {
return false, err
}
// Check if the IP is in the CIDR range
return cidr.Contains(ipAddr), nil
}
// Parse the filter as a single IP
ipFilter := net.ParseIP(filter)
// Check if the IP is valid
if ipFilter == nil {
return false, errors.New("invalid IP address in filter")
}
// Check if the IP matches the filter
if ipFilter.Equal(ipAddr) {
return true, nil
}
// If the filter is not a CIDR range or a single IP, return false
return false, nil
}