├── .github └── workflows │ └── coveralls.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── config ├── config.go ├── user.go ├── user_test.go └── webconfig.go ├── debian ├── changelog ├── compat ├── control ├── glauth-ui-light.install ├── glauth-ui-light.postinstall ├── glauth-ui-light.service ├── glauth-ui.cfg ├── patches │ ├── fix-makefile-glauth-ui-light │ └── series └── rules ├── go.mod ├── go.sum ├── handlers ├── _sample-simple.cfg.orig ├── db.go ├── db_test.go ├── global.go ├── global_test.go ├── groups.go ├── groups_test.go ├── login.go ├── login_test.go ├── userProfile.go ├── userProfile_test.go ├── users.go └── users_test.go ├── helpers ├── _sample-simple.cfg.orig ├── auth_test.go ├── cookies.go ├── db.go ├── db_test.go ├── fail_test.go ├── global_test.go ├── i18n.go ├── login_test.go └── sessions.go ├── img ├── 1-home.png ├── 2-login.png ├── 3-changepass.png ├── 3-otp.png ├── 4-usersedit.png ├── 4-userspage.png ├── 5-delgroup.png ├── 6-responsive.png └── 6-responsive2.png ├── locales ├── aaaa │ └── en.yml ├── de-DE │ └── de.yml ├── en-US │ └── en.yml ├── es-ES │ └── es.yml ├── fr-FR │ └── fr.yml └── tr.yml ├── main.go ├── routes ├── auth_test.go ├── routes.go └── web │ ├── assets │ ├── css │ │ ├── bootstrap-icons.css │ │ ├── bootstrap.min.css │ │ ├── bootstrap.min.css.map │ │ └── fonts │ │ │ ├── bootstrap-icons.woff │ │ │ └── bootstrap-icons.woff2 │ ├── favicon.ico │ └── js │ │ ├── Nibbler.js │ │ ├── bootstrap.bundle.min.js │ │ ├── bootstrap.bundle.min.js.map │ │ └── jquery.min.js │ └── templates │ ├── global │ ├── footer.tmpl │ └── header.tmpl │ ├── group │ ├── create.tmpl │ ├── edit.tmpl │ └── list.tmpl │ ├── home │ ├── error.tmpl │ ├── index.tmpl │ └── login.tmpl │ └── user │ ├── create.tmpl │ ├── edit.tmpl │ ├── list.tmpl │ └── profile.tmpl ├── samples-conf ├── glauth-sample-simple.cfg └── webconfig.cfg └── script └── diff-cfg.sh /.github/workflows/coveralls.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | jobs: 3 | 4 | test: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | go: ['1.16', '1.17'] 10 | 11 | steps: 12 | - uses: actions/setup-go@v1 13 | with: 14 | go-version: ${{ matrix.go }} 15 | - uses: actions/checkout@v2 16 | - run: go test -v -coverprofile=profile.cov ./... 17 | 18 | - name: Send coverage 19 | uses: shogo82148/actions-goveralls@v1 20 | with: 21 | path-to-profile: profile.cov 22 | flag-name: Go-${{ matrix.go }} 23 | parallel: true 24 | 25 | # notifies that all test jobs are finished. 26 | finish: 27 | needs: test 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: shogo82148/actions-goveralls@v1 31 | with: 32 | parallel-finished: true 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | *.swp 8 | build/ 9 | logs/ 10 | *.pem 11 | *.crt 12 | *.key 13 | 14 | # Test binary, built with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters-settings: 2 | errcheck: 3 | check-type-assertions: true 4 | goconst: 5 | min-len: 2 6 | min-occurrences: 5 7 | gocritic: 8 | disabled-checks: 9 | - commentedOutCode 10 | enabled-tags: 11 | - diagnostic 12 | #- style 13 | - experimental 14 | #- opinionated 15 | - performance 16 | settings: # settings passed to gocritic 17 | rangeExprCopy: 18 | sizeThreshold: 16 19 | rangeValCopy: 20 | sizeThreshold: 16 21 | goimports: 22 | local-prefixes: github.com/ayoisaiah/f2 23 | govet: 24 | check-shadowing: true 25 | 26 | 27 | 28 | linters: 29 | disable-all: true 30 | enable: 31 | - bodyclose 32 | - deadcode 33 | - depguard 34 | - dogsled 35 | - dupl 36 | - errcheck 37 | - errorlint 38 | - exportloopref 39 | - exhaustive 40 | - goconst 41 | - godot 42 | - gocritic 43 | - gofmt 44 | - goimports 45 | - gocyclo 46 | - goprintffuncname 47 | - gosec 48 | - gosimple 49 | - govet 50 | - ineffassign 51 | - misspell 52 | - nakedret 53 | - nolintlint 54 | - prealloc 55 | - predeclared 56 | - revive 57 | - staticcheck 58 | - structcheck 59 | - stylecheck 60 | - thelper 61 | - tparallel 62 | - typecheck 63 | - unconvert 64 | - unparam 65 | - varcheck 66 | - whitespace 67 | 68 | issues: 69 | fix: true 70 | exclude-rules: 71 | - path: _test\.go # disable some linters for test files 72 | linters: 73 | - gocyclo 74 | - gosec 75 | - dupl 76 | #- linters: 77 | # - gosec 78 | # text: "weak cryptographic primitive" 79 | - linters: 80 | - stylecheck 81 | text: "error strings should not be capitalized" 82 | - linters: 83 | - stylecheck 84 | text: "ST1003:" 85 | - linters: 86 | - stylecheck 87 | text: "ST1001: should not use dot imports" 88 | - linters: 89 | - revive 90 | text: "var-naming:" 91 | - linters: 92 | - revive 93 | text: "dot-imports:" 94 | - linters: 95 | - errcheck 96 | text: "Error return value is not checked" 97 | max-issues-per-linter: 0 98 | max-same-issues: 0 99 | 100 | run: 101 | issues-exit-code: 1 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 yvesago 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | VERSION=$(shell git describe --abbrev=0 --tags) 3 | 4 | BUILD=$(shell git rev-parse --short HEAD) 5 | DATE=$(shell date +%FT%T%z) 6 | 7 | # Binaries to be build 8 | PLATFORMS = linux/glauth-ui windows/glauth-ui.exe darwin/glauth-ui-app 9 | BINS = $(wildcard build/*/*) 10 | 11 | # functions 12 | temp = $(subst /, ,$@) 13 | os = $(word 1, $(temp)) 14 | 15 | # Setup the -ldflags option for go building, interpolate the variable values 16 | LDFLAGS=-trimpath -ldflags "-w -s -X 'glauth-ui-light/handlers.Version=${VERSION}, git: ${BUILD}, build: ${DATE}'" 17 | 18 | # Build binaries 19 | # first build : linux/glauth-ui 20 | $(PLATFORMS): 21 | @mkdir -p build/${os} 22 | CGO_ENABLED=0 GOOS=${os} go build ${LDFLAGS} -o build/$@ 23 | @echo " => bin builded: build/$@" 24 | 25 | build: $(PLATFORMS) 26 | 27 | # List binaries 28 | $(BINS): 29 | @echo "==============" 30 | @echo "Release text :" 31 | @echo " ${VERSION}, git: ${BUILD}" 32 | @sha256sum $@ 33 | 34 | sha: $(BINS) 35 | 36 | # Cleans our project: deletes binaries 37 | clean: 38 | rm -rf build/ 39 | @echo "Build cleaned" 40 | 41 | debclean: 42 | @quilt pop 43 | @dh clean 44 | 45 | deb: 46 | debuild -us -uc -b 47 | @mkdir -p build/linux/ 48 | cp debian/glauth-ui-light/usr/bin/glauth-ui-light build/linux/glauth-ui 49 | @echo "==============" 50 | @echo "Release text :" 51 | @echo " ${VERSION}, git: ${BUILD}" 52 | sha256sum debian/glauth-ui-light/usr/bin/glauth-ui-light 53 | 54 | tr: 55 | @echo "shell is $$0" 56 | rgrep -hoP '{{ tr "(.*?)" }}' routes/web/templates/ | sed "s/{{ tr \"//" | sed "s/\" }}/: \"\"/" | sort | uniq > tr.tmp 57 | rgrep -hoP 'Tr\(lang, "(.*?)"' handlers/* | sed "s/Tr(lang, \"//" | sort | uniq | awk -F'"' '{print $$1": \"\""}' >> tr.tmp 58 | cat tr.tmp | sort | uniq > locales/tr.yml 59 | rm tr.tmp 60 | 61 | 62 | 63 | all: build 64 | 65 | .PHONY: clean build sha tr $(BINS) $(PLATFORMS) 66 | 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # glauth-ui-light 2 | 3 | ## Description 4 | 5 | **glauth-ui-light** is a small golang web app to manage users and groups from the db files of [glauth ldap server](https://github.com/glauth/glauth) for small business, self hosted, labs infrastructure or raspbery servers. 6 | 7 | [![Coverage Status](https://coveralls.io/repos/github/yvesago/glauth-ui-light/badge.svg?branch=main)](https://coveralls.io/github/yvesago/glauth-ui-light?branch=main) 8 | 9 | Thanks of hot-reload feature on glauth configuration file change, **glauth-ui-light** can edit and update glauth configuration and manage ldap users. 10 | 11 | **glauth-ui-light** update only users and groups. 12 | 13 | All lines after the first ``[[users]]`` in glauth configuration file will be updated. The use of same model structure than glauth V2 allow to keep manual edition of non managed features as ``capabilities, loginShell, sshkeys, otpsecret, yubikey, includegroups, ...``. Only comments on these lines are lost. 14 | 15 | Current glauth experimental feature on ``customattributes`` is lost due to the need of a patched ``toml`` library. 16 | 17 | 18 | ## Main aims 19 | 20 | - Static binary with small customisations 21 | - Keep abbility to edit glauth configuration file 22 | - Minimal javascript use 23 | 24 | 25 | ## Current features 26 | 27 | - Custom front page text and application name 28 | - Add new localisations by adding new yaml file 29 | - Authentication with current glauth users if ``allowreadssha256`` is set 30 | - Admin right is defined for members of group defined in ``gidadmin`` 31 | - Users in group ``gidcanchgpass`` are common users and can change their passwords 32 | - Users in group ``giduseotp`` can define and use an One Time Password application like Google Authenticator, andOTP, ... 33 | 34 | 35 | 36 | ## Missing glauth V2 features 37 | 38 | Current glauth experimental feature on ``customattributes`` is lost due to the need of a patched toml library. 39 | 40 | 41 | ## Some overkill features for a self hosted infrastructure 42 | 43 | - only register bcrypt passwords 44 | - daily log rotates 45 | - i18n support 46 | - responsive UI 47 | - TOTP management 48 | - Bcrypt tokens to bypass TOTP 49 | - delayed after 4 failed login 50 | - rate requests limiter against brute force attempts 51 | - password strength 52 | - CSRF 53 | - STS, CSP 54 | - standalone SSL or via reverse proxy 55 | - high tests coverage 56 | - windows, macos builds (not tested) 57 | 58 | 59 | ## Limits 60 | 61 | Allow users to self change their passwords can create bad concurrent updates of glauth configuration file. 62 | 63 | ## Install binary 64 | 65 | **Download** last release from https://github.com/yvesago/glauth-ui-light/releases 66 | 67 | 68 | **Create config file :** 69 | ``` 70 | ####################### 71 | # glauth-ui-light.conf 72 | 73 | # dbfile: glauth conf file 74 | # with watchconfig enabled for hot-reload on conf file change 75 | # glauth-ui-light need write access 76 | dbfile = "samples-conf/glauth-sample-simple.cfg" 77 | 78 | # run on a non privileged port 79 | port = "0.0.0.0:8080" 80 | # When a self hosted ssl reverse proxy is used : 81 | # port = "127.0.0.1:8080" 82 | 83 | # Custom first page texts 84 | appname = "glauth-ui-light" 85 | appdesc = "Manage users and groups for glauth ldap server" 86 | 87 | # Simplfy amdin ui when no need for otp 88 | maskotp = false 89 | 90 | # optional default unix fields 91 | defaulthomedir = "/home" 92 | defaultloginshell = "/bin/false" 93 | 94 | [sec] 95 | # TODO set random secrets for CSRF token 96 | csrfrandom = "secret1" 97 | 98 | [passpolicy] 99 | min = 2 100 | max = 24 101 | allowreadssha256 = true # to be set to false when all passwords are bcrypt 102 | entropy = 60 # optional password constraint 103 | 104 | 105 | [cfgusers] 106 | start = 5000 # start with this uid number 107 | gidadmin = 5501 # members of this group are admins 108 | gidcanchgpass = 5500 # members of this group can change their password 109 | giduseotp = 5501 # members of this group use OTP 110 | 111 | [cfggroups] 112 | start = 5500 # start with this gid number 113 | ``` 114 | 115 | 116 | **Start :** 117 | ``` 118 | $ ./glauth-ui -c glauth-ui-light.conf & 119 | 120 | $ firefox http://localhost:8080/ 121 | 122 | ``` 123 | 124 | ## Install Debian package 125 | 126 | Download last deb file from https://github.com/yvesago/glauth-ui-light/releases 127 | 128 | ``` 129 | $ sudo dpkg -i glauth-ui-light_1.2.0-0~static0_amd64.deb 130 | 131 | $ systemctl status glauth-ui-light 132 | ● glauth-ui-light.service - Glauth web 133 | Loaded: loaded (/lib/systemd/system/glauth-ui-light.service; enabled; vendor preset: enabled) 134 | Active: active (running) since Tue 2022-02-15 18:14:54 CET; 2h 20min ago 135 | Main PID: 119451 (glauth-ui-light) 136 | Tasks: 7 (limit: 4475) 137 | Memory: 5.6M 138 | CGroup: /system.slice/glauth-ui-light.service 139 | └─119451 /usr/bin/glauth-ui-light -c /etc/glauth-ui/glauth-ui.cfg 140 | 141 | # custom config 142 | $ vi /etc/glauth-ui/glauth-ui.cfg 143 | 144 | # logs 145 | $ tail -f /var/log/glauth-ui/app.20220208 146 | 147 | ``` 148 | 149 | ## Usage 150 | 151 | **Home** 152 | ![Home](img/1-home.png) 153 | 154 | **Login** 155 | ![Login](img/2-login.png) 156 | 157 | **Change password** 158 | ![Change password](img/3-changepass.png) 159 | 160 | **TOTP** 161 | ![TOTP](img/3-otp.png) 162 | 163 | **Manage users** 164 | ![Users page](img/4-userspage.png) 165 | 166 | **Edit user** 167 | ![Edit user](img/4-usersedit.png) 168 | 169 | **Delete group** 170 | ![Delete group](img/5-delgroup.png) 171 | 172 | **Responsive** 173 | 174 | ![Responsive](img/6-responsive.png) 175 | ![Responsive 2](img/6-responsive2.png) 176 | 177 | 178 | ## Localisation 179 | ``cp locales/tr.yml locales/it-IT/it.yml`` 180 | 181 | Translate strings and add new local to config file 182 | 183 | ``` 184 | ... 185 | [locale] 186 | lang = "it" 187 | path = "locales/" 188 | langs = ["en-US","fr-FR","it-IT"] 189 | ... 190 | 191 | ``` 192 | 193 | 194 | ## Build binary 195 | 196 | ``` 197 | $ git clone https://github.com/yvesago/glauth-ui-light.git 198 | 199 | $ cd glauth-ui-light 200 | 201 | $ make 202 | 203 | # to build binary AND debian/ubuntu package 204 | $ make deb 205 | 206 | ``` 207 | 208 | Tests: 209 | ``` 210 | # linter 211 | $ golangci-lint run ./... 212 | 213 | # tests 214 | $ go test ./... 215 | ? glauth-ui-light [no test files] 216 | ok glauth-ui-light/config 0.284s 217 | ok glauth-ui-light/handlers 0.246s 218 | ok glauth-ui-light/helpers 18.048s # failed login tests need 18s 219 | ok glauth-ui-light/routes 0.019s 220 | 221 | 222 | # test coverage 223 | $ go test -coverprofile=coverage.out ./... 224 | 225 | $ go tool cover -func=coverage.out 226 | ... 227 | glauth-ui-light/routes/routes.go:79: initServer 85.4% 228 | glauth-ui-light/routes/routes.go:182: SetRoutes 93.9% 229 | glauth-ui-light/routes/routes.go:230: contains 100.0% 230 | glauth-ui-light/routes/routes.go:239: setCacheHeaders 100.0% 231 | glauth-ui-light/routes/routes.go:258: Auth 100.0% 232 | total: (statements) 95.1% 233 | 234 | # html browser output 235 | $ go tool cover -html=coverage.out 236 | ``` 237 | 238 | ## Deploy 239 | 240 | ``` 241 | $ scp -pr locales admin@server:/home/app/glauth-ui-light 242 | 243 | $ scp build/linux/glauth-ui admin@server:/home/app/glauth-ui-light 244 | ``` 245 | 246 | ## Build debian/ubuntu package 247 | 248 | ``` 249 | $ apt install build-essential quilt 250 | 251 | $ git clone https://github.com/yvesago/glauth-ui-light.git 252 | $ cd glauth-ui-light 253 | 254 | $ make deb 255 | 256 | # view content 257 | $ dpkg-deb -c ../glauth-ui-light_1.2.0-0~static0_amd64.deb 258 | 259 | # install 260 | $ sudo dpkg -i ../glauth-ui-light_1.2.0-0~static0_amd64.deb 261 | ``` 262 | 263 | ### Code structure: 264 | ``` 265 | main.go 266 | |-config 267 | |-glauth-config-v2.go // glauth users goups models 268 | |-webconfig.go 269 | |-user.go // user methods 270 | |-helpers 271 | |-cookie.go // cookies for session 272 | |-db.go // read write data 273 | |-18n.go // i18n 274 | |-sessions.go // manage session 275 | |-handler 276 | |-global.go // global var, render 277 | |-login.go 278 | |-users.go 279 | |-userProfile.go 280 | |-groups.go 281 | |- routes 282 | |-routes.go // load template, i18n. Set routes, auth middleware 283 | |-web // due to "embed" files 284 | |-assets 285 | |-css 286 | |-bootstrap.css 287 | |-....css 288 | |-js 289 | |-....js 290 | |-templates 291 | |-global 292 | |-header.tmpl 293 | |-footer.tmpl 294 | |-home 295 | |-index.tmpl 296 | |-login.tmpl 297 | |-user 298 | |-list.tmpl 299 | |-add.tmpl 300 | |-edit.tmpl 301 | |-profile.tmpl 302 | |-group 303 | |-list.tmpl 304 | |-add.tmpl 305 | |-edit.tmpl 306 | |-locales 307 | |-en-US 308 | |-fr-FR 309 | |-samples-conf 310 | |-webconfig.cfg 311 | |-glauthconfig.cfg 312 | 313 | ``` 314 | 315 | 316 | ## References 317 | 318 | https://github.com/demo-apps/go-gin-app 319 | 320 | https://etienner.github.io/connexion-deconnexion-avec-gin/ 321 | 322 | https://www.alexedwards.net/blog/form-validation-and-processing 323 | 324 | https://vincent.bernat.ch/en/blog/2019-pragmatic-debian-packaging 325 | 326 | https://github.com/wagslane/go-password-validator 327 | 328 | 329 | ## Changelog 330 | 331 | v1.4.4: 332 | * Show SSH keys 333 | * Fix Mask OTP 334 | 335 | v1.4.3: 336 | * Fix issue #4 with on password entropy. Thx to KaptinLin 337 | * Improve UI. Thx to KaptinLin 338 | * Add german translation. Thx to publicdesert 339 | * Add user unix fields 340 | 341 | v1.4.2: 342 | * Add spanish translations. Thx to Iago Sardiña. 343 | 344 | v1.4.1: 345 | * Add optional password strength constraint 346 | 347 | 348 | v1.4.0: 349 | * Add app passwords (tokens) to bypass ldap OTP (bcrypt only) 350 | * fix denied changes on Lock 351 | 352 | 353 | v1.2.0: 354 | * Add OTP management 355 | * tweak UI 356 | 357 | 358 | v1.0.1: 359 | * fix keep unchanged old sha256 password 360 | * fix UI mistakes 361 | 362 | 363 | v1.0.0: 364 | * initial release 365 | 366 | 367 | 368 | ## Licence 369 | 370 | MIT License 371 | 372 | Copyright (c) 2023 Yves Agostini 373 | 374 | 375 | 376 | 377 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "time" 4 | 5 | // config file. 6 | type Backend struct { 7 | BaseDN string 8 | Datastore string 9 | Insecure bool // For LDAP and owncloud backend only 10 | Servers []string // For LDAP and owncloud backend only 11 | NameFormat string 12 | GroupFormat string 13 | SSHKeyAttr string 14 | UseGraphAPI bool // For ownCloud backend only 15 | Plugin string // Path to plugin library, for plugin backend only 16 | PluginHandler string // Name of plugin's main handler function 17 | Database string // For Database backends only 18 | AnonymousDSE bool // For Config and Database backends only 19 | } 20 | type Helper struct { 21 | Enabled bool 22 | BaseDN string 23 | Datastore string 24 | Plugin string // Path to plugin library, for plugin backend only 25 | PluginHandler string // Name of plugin's main handler function 26 | Database string // For MySQL backend only TODO REname to match plugin 27 | } 28 | type Frontend struct { 29 | AllowedBaseDNs []string // For LDAP backend only 30 | Listen string 31 | Cert string 32 | Key string 33 | TLS bool 34 | } 35 | type LDAP struct { 36 | Enabled bool 37 | Listen string 38 | } 39 | type LDAPS struct { 40 | Enabled bool 41 | Listen string 42 | Cert string 43 | Key string 44 | } 45 | type API struct { 46 | Cert string 47 | Enabled bool 48 | Internals bool 49 | Key string 50 | Listen string 51 | SecretToken string 52 | TLS bool 53 | } 54 | type Behaviors struct { 55 | IgnoreCapabilities bool 56 | LimitFailedBinds bool 57 | NumberOfFailedBinds int 58 | PeriodOfFailedBinds time.Duration 59 | BlockFailedBindsFor time.Duration 60 | PruneSourceTableEvery time.Duration 61 | PruneSourcesOlderThan time.Duration 62 | } 63 | type Capability struct { 64 | Action string `toml:"action,omitempty"` 65 | Object string `toml:"object,omitempty"` 66 | } 67 | type User struct { 68 | Name string `toml:"name,omitempty"` 69 | OtherGroups []int `toml:"othergroups,omitempty"` 70 | PassSHA256 string `toml:"passsha256,omitempty"` 71 | PassBcrypt string `toml:"passbcrypt,omitempty"` 72 | PassAppSHA256 []string `toml:"passappsha256,omitempty"` 73 | PassAppBcrypt []string `toml:"passappbcrypt,omitempty"` 74 | PrimaryGroup int `toml:"primarygroup,omitempty"` 75 | Capabilities []Capability `toml:"capabilities,omitempty"` 76 | SSHKeys []string `toml:"sshkeys,omitempty"` 77 | OTPSecret string `toml:"otpsecret,omitempty"` 78 | Yubikey string `toml:"yubikey,omitempty"` 79 | Disabled bool `toml:"disabled,omitempty"` 80 | // UnixID int `toml:"unixid,omitempty"` // TODO: remove after deprecating UnixID on User and Group 81 | UIDNumber int `toml:"uidnumber,omitempty"` 82 | Mail string `toml:"mail,omitempty"` 83 | LoginShell string `toml:"loginShell,omitempty"` 84 | GivenName string `toml:"givenname,omitempty"` 85 | SN string `toml:"sn,omitempty"` 86 | Homedir string `toml:"homeDir,omitempty"` 87 | CustomAttrs map[string]interface{} `toml:"customattrs,omitempty"` 88 | } 89 | type Group struct { 90 | Name string `toml:"name,omitempty"` 91 | // UnixID int `toml:"unixid,omitempty"` // TODO: remove after deprecating UnixID on User and Group 92 | GIDNumber int `toml:"gidnumber,omitempty"` 93 | IncludeGroups []int `toml:"includegroups,omitempty"` 94 | } 95 | type Config struct { 96 | API API 97 | Backend Backend // Deprecated 98 | Backends []Backend 99 | Helper Helper 100 | Behaviors Behaviors 101 | Debug bool 102 | WatchConfig bool 103 | YubikeyClientID string 104 | YubikeySecret string 105 | Frontend Frontend 106 | LDAP LDAP 107 | LDAPS LDAPS 108 | Groups []Group 109 | Syslog bool 110 | Users []User 111 | ConfigFile string 112 | AwsAccessKeyId string 113 | AwsSecretAccessKey string 114 | AwsRegion string 115 | } 116 | -------------------------------------------------------------------------------- /config/user.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | 7 | "github.com/pquerna/otp/hotp" 8 | "github.com/pquerna/otp/totp" 9 | "golang.org/x/crypto/bcrypt" 10 | ) 11 | 12 | // Add User password methods 13 | 14 | func (u *User) ValidPass(pass string, allowsha256 bool) bool { 15 | if allowsha256 { 16 | if u.ValidSHA256Pass(pass) { 17 | return true 18 | } 19 | return u.ValidBcryptPass(pass) 20 | } 21 | return u.ValidBcryptPass(pass) 22 | } 23 | 24 | func (u *User) ValidSHA256Pass(pass string) bool { 25 | if u.PassSHA256 == "" { 26 | return false 27 | } 28 | 29 | hashFull := sha256.New() 30 | hashFull.Write([]byte(pass)) 31 | return u.PassSHA256 == hex.EncodeToString(hashFull.Sum(nil)) 32 | } 33 | 34 | func (u *User) SetSHA256Pass(pass string) { 35 | hashFull := sha256.New() 36 | hashFull.Write([]byte(pass)) 37 | u.PassSHA256 = hex.EncodeToString(hashFull.Sum(nil)) 38 | } 39 | 40 | func (u *User) ValidBcryptPass(pass string) bool { 41 | if u.PassBcrypt == "" { 42 | return false 43 | } 44 | 45 | decoded, _ := hex.DecodeString(u.PassBcrypt) 46 | return bcrypt.CompareHashAndPassword(decoded, []byte(pass)) == nil 47 | } 48 | 49 | func (u *User) SetBcryptPass(pass string) { 50 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) 51 | if err == nil { 52 | u.PassBcrypt = hex.EncodeToString(hashedPassword) 53 | } 54 | } 55 | 56 | func (u *User) ValidOTP(code string, prod bool) bool { 57 | if prod { 58 | return totp.Validate(code, u.OTPSecret) 59 | } 60 | return hotp.Validate(code, 1, u.OTPSecret) // for tests 61 | } 62 | 63 | // passApp methods 64 | 65 | func (u *User) AddPassApp(pass string) { 66 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) 67 | if err == nil { 68 | u.PassAppBcrypt = append(u.PassAppBcrypt, hex.EncodeToString(hashedPassword)) 69 | } 70 | } 71 | 72 | func (u *User) DelPassApp(k int) { 73 | if k < len(u.PassAppBcrypt) && k >= 0 { 74 | u.PassAppBcrypt = append(u.PassAppBcrypt[:k], u.PassAppBcrypt[k+1:]...) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /config/user_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | "testing" 7 | 8 | "encoding/base32" 9 | 10 | "github.com/pquerna/otp/hotp" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | var Data Ctmp 16 | 17 | func initUsersValues() { 18 | v1 := User{ 19 | Name: "user1", 20 | UIDNumber: 5000, 21 | PrimaryGroup: 6501, 22 | PassSHA256: "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a", 23 | OTPSecret: "3hnvnk4ycv44glzigd6s25j4dougs3rk", 24 | } 25 | Data.Users = append(Data.Users, v1) 26 | v2 := User{ 27 | Name: "user2", 28 | UIDNumber: 5001, 29 | PrimaryGroup: 6504, 30 | PassBcrypt: "243261243130244B62463462656F7265504F762E794F324957746D656541326B4B46596275674A79336A476845764B616D65446169784E41384F4432", 31 | } 32 | Data.Users = append(Data.Users, v2) 33 | v3 := User{ 34 | Name: "user3", 35 | UIDNumber: 5001, 36 | PrimaryGroup: 6505, 37 | } 38 | Data.Users = append(Data.Users, v3) 39 | } 40 | 41 | func resetData() { 42 | Data.Users = []User{} 43 | } 44 | 45 | func TestUserModel(t *testing.T) { 46 | defer resetData() 47 | 48 | cfg := WebConfig{ 49 | DBfile: "sample-simple.cfg", 50 | Debug: true, 51 | Tests: true, 52 | CfgUsers: CfgUsers{ 53 | Start: 5000, 54 | GIDAdmin: 6501, 55 | GIDcanChgPass: 6500, 56 | }, 57 | PassPolicy: PassPolicy{ 58 | AllowReadSSHA256: true, 59 | Entropy: 60, 60 | }, 61 | } 62 | 63 | initUsersValues() 64 | 65 | sha256User := Data.Users[0] 66 | bcryptUser := Data.Users[1] 67 | nopassUser := Data.Users[2] 68 | 69 | // Test passwords 70 | log.Println("= Test passwords") 71 | 72 | assert.Equal(t, false, sha256User.ValidPass("badpass", cfg.PassPolicy.AllowReadSSHA256), "unvalid sha256 pass") 73 | assert.Equal(t, false, sha256User.ValidPass("badpass", false), "unvalid sha256 pass") 74 | assert.Equal(t, false, bcryptUser.ValidPass("badpass", false), "unvalid sha256 pass") 75 | 76 | assert.Equal(t, true, sha256User.ValidPass("dogood", cfg.PassPolicy.AllowReadSSHA256), "valid sha256 pass") 77 | assert.Equal(t, true, bcryptUser.ValidPass("dogood", false), "valid bcrypt pass") 78 | assert.Equal(t, true, bcryptUser.ValidPass("dogood", true), "valid bcrypt pass with sha256 not set") 79 | 80 | assert.Equal(t, false, sha256User.ValidPass("dogood", false), "sha256 pass forbidden") 81 | 82 | assert.Equal(t, false, nopassUser.ValidPass("dogood", cfg.PassPolicy.AllowReadSSHA256), "unvalid user without pass") 83 | assert.Equal(t, false, nopassUser.ValidPass("dogood", false), "unvalid user without pass") 84 | 85 | // Set passwords 86 | log.Println("= Set passwords") 87 | sha256User.SetSHA256Pass("otherpass") 88 | assert.Equal(t, true, sha256User.ValidPass("otherpass", cfg.PassPolicy.AllowReadSSHA256), "change sha256 pass") 89 | 90 | bcryptUser.SetBcryptPass("otherpass") 91 | assert.Equal(t, true, bcryptUser.ValidPass("otherpass", false), "change bcrypt pass") 92 | 93 | otpgood := "3hnvnk4ycv44glzigd6s25j4dougs3rk" 94 | otpgood2 := "gvxdgn3hpfvwu2lhmz3gmm3z" 95 | otpbad := "810bk3t6mdt5j579m29mjm" 96 | _, err := base32.StdEncoding.DecodeString(strings.ToUpper(otpgood)) 97 | assert.Equal(t, nil, err, "good otp") 98 | _, err = base32.StdEncoding.DecodeString(strings.ToUpper(otpgood2)) 99 | assert.Equal(t, nil, err, "good otp2") 100 | _, err = base32.StdEncoding.DecodeString(strings.ToUpper(otpbad)) 101 | assert.Equal(t, "illegal base32 data at input byte 0", err.Error(), "bad otp") 102 | 103 | passcode, _ := hotp.GenerateCode(otpgood, 1) 104 | log.Println(passcode) 105 | 106 | valid := hotp.Validate(passcode, 1, otpgood) 107 | log.Println(valid) 108 | sha256User.OTPSecret = otpgood 109 | assert.Equal(t, true, sha256User.ValidOTP(passcode, false), "Valid hotp") 110 | assert.Equal(t, false, sha256User.ValidOTP(passcode, true), "Bad totp") 111 | } 112 | 113 | func TestPassApp(t *testing.T) { 114 | defer resetData() 115 | 116 | initUsersValues() 117 | 118 | bcryptUser := Data.Users[1] 119 | 120 | // Test pass app 121 | log.Println("= Add brcypt pass app") 122 | bcryptUser.AddPassApp("test1") 123 | bcryptUser.AddPassApp("test2") 124 | bcryptUser.AddPassApp("test3") 125 | assert.Equal(t, 3, len(bcryptUser.PassAppBcrypt), "3 pass app") 126 | assert.Equal(t, 120, len(bcryptUser.PassAppBcrypt[0]), "bcrypt pass app") 127 | // log.Printf("%+v\n", bcryptUser.PassAppBcrypt) 128 | 129 | bcryptUser.DelPassApp(1) 130 | assert.Equal(t, 2, len(bcryptUser.PassAppBcrypt), "remove pass 1") 131 | // log.Printf("%+v\n", bcryptUser.PassAppBcrypt) 132 | 133 | bcryptUser.DelPassApp(3) 134 | assert.Equal(t, 2, len(bcryptUser.PassAppBcrypt), "do nothing on bad index") 135 | 136 | bcryptUser.DelPassApp(-1) 137 | assert.Equal(t, 2, len(bcryptUser.PassAppBcrypt), "do nothing on bad index") 138 | } 139 | -------------------------------------------------------------------------------- /config/webconfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type Ctmp struct { 4 | Users []User `toml:"users,omitempty"` 5 | Groups []Group `toml:"groups,omitempty"` 6 | } 7 | 8 | type WebConfig struct { 9 | AppName string 10 | AppDesc string 11 | DefaultHomedir string 12 | DefaultLoginShell string 13 | MaskOTP bool 14 | Port string 15 | DBfile string 16 | Debug bool 17 | Tests bool 18 | Sec Sec 19 | SSL SSL 20 | Logs Logs 21 | Locale Locale 22 | PassPolicy PassPolicy 23 | CfgUsers CfgUsers 24 | CfgGroups CfgGroups 25 | } 26 | 27 | type Sec struct { 28 | CSRFrandom string 29 | TrustedProxies []string 30 | } 31 | 32 | type SSL struct { 33 | Crt string 34 | Key string 35 | } 36 | 37 | type Logs struct { 38 | Path string 39 | RotationCount uint 40 | } 41 | 42 | type Locale struct { 43 | Lang string 44 | Path string 45 | Langs []string 46 | } 47 | 48 | type PassPolicy struct { 49 | Min int 50 | Max int 51 | AllowReadSSHA256 bool 52 | Entropy int64 53 | } 54 | 55 | type CfgUsers struct { 56 | Start int 57 | GIDAdmin int 58 | GIDcanChgPass int 59 | GIDuseOtp int 60 | } 61 | 62 | type CfgGroups struct { 63 | Start int 64 | } 65 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | glauth-ui-light (1.0.0) UNRELEASED; urgency=medium 2 | 3 | * Initial release. 4 | 5 | -- yvesago Tue, 08 Feb 2022 14:58:21 +0100 6 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 11 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: glauth-ui-light 2 | Maintainer: YvesAgo 3 | Build-Depends: debhelper (>= 11) 4 | 5 | Package: glauth-ui-light 6 | Architecture: amd64 7 | Depends: adduser 8 | Description: web app to manage users and groups from glauth ldap server 9 | glauth-ui-light is a small golang web app to manage users and groups from 10 | the db files of glauth ldap server for small business, self hosted, 11 | labs infrastructure or raspbery servers. 12 | https://github.com/yvesago/glauth-ui-light 13 | -------------------------------------------------------------------------------- /debian/glauth-ui-light.install: -------------------------------------------------------------------------------- 1 | locales etc/glauth-ui 2 | debian/glauth-ui.cfg etc/glauth-ui 3 | -------------------------------------------------------------------------------- /debian/glauth-ui-light.postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | case "$1" in 6 | configure) 7 | adduser --system --disabled-password --disabled-login --home /var/empty \ 8 | --no-create-home --quiet --force-badname --group glauth 9 | chmod 755 /etc/glauth-ui 10 | chmod 644 /etc/glauth-ui/* 11 | chown glauth:glauth /etc/glauth-ui 12 | chown glauth:glauth /etc/glauth-ui/* 13 | ;; 14 | esac 15 | 16 | #DEBHELPER# 17 | 18 | exit 0 19 | -------------------------------------------------------------------------------- /debian/glauth-ui-light.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Glauth web 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=glauth 8 | ExecStart=/usr/bin/nohup /usr/bin/glauth-ui-light -c /etc/glauth-ui/glauth-ui.cfg 9 | 10 | [Install] 11 | WantedBy=multi-user.target 12 | -------------------------------------------------------------------------------- /debian/glauth-ui.cfg: -------------------------------------------------------------------------------- 1 | ####################### 2 | # glauth-ui-light.conf 3 | 4 | # dbfile: glauth conf file 5 | # with watchconfig enabled for hot-reload on conf file change 6 | # glauth-ui-light need write access 7 | dbfile = "/etc/glauth/sample-simple.cfg" 8 | 9 | # run on a non privileged port 10 | port = "0.0.0.0:8080" 11 | # When a self hosted ssl reverse proxy is used : 12 | # port = "127.0.0.1:8080" 13 | 14 | # Custom first page texts 15 | appname = "glauth-ui-light" 16 | appdesc = "Manage users and groups for glauth ldap server" 17 | 18 | [sec] 19 | trustedproxies = ["127.0.0.1","::1"] 20 | # TODO set random secrets for CSRF token 21 | csrfrandom = "secret1" 22 | 23 | # to enable https generate a certificate, eg. with: 24 | # openssl req -x509 -newkey rsa:4096 -keyout glauthui.key -out glauthui.crt -days 365 -nodes -subj '/CN=`hostname`' 25 | #[ssl] 26 | # crt = "conf/glauthui.crt" 27 | # key = "conf/glauthui.key" 28 | 29 | [logs] 30 | path = "/var/log/glauth-ui/" 31 | rotationcount = 7 # keep 7 days of logs 32 | 33 | [locale] 34 | lang = "en" 35 | path = "/etc/glauth-ui/locales/" 36 | langs = ["en-US","fr-FR"] 37 | 38 | [passpolicy] 39 | min = 2 40 | max = 24 41 | allowreadssha256 = true # to be set to false when all passwords are bcrypt 42 | 43 | [cfgusers] 44 | start = 5000 # start with this uid number 45 | gidadmin = 5501 # members of this group are admins 46 | gidcanchgpass = 5500 # members of this group can change their password 47 | 48 | [cfggroups] 49 | start = 5500 # start with this gid number 50 | -------------------------------------------------------------------------------- /debian/patches/fix-makefile-glauth-ui-light: -------------------------------------------------------------------------------- 1 | --- a/Makefile 2 | +++ b/Makefile 3 | @@ -1,3 +1,5 @@ 4 | +export GOROOT = /snap/go/current 5 | +export GOBIN = /snap/go/current/bin/go 6 | 7 | VERSION=$(shell git describe --abbrev=0 --tags) 8 | 9 | @@ -42,7 +44,8 @@ 10 | cat tr.tmp | sort | uniq > locales/tr.yml 11 | rm tr.tmp 12 | 13 | - 14 | +install: 15 | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 ${GOBIN} build ${LDFLAGS} -o $(DESTDIR)/usr/bin/glauth-ui-light 16 | 17 | all: build 18 | 19 | -------------------------------------------------------------------------------- /debian/patches/series: -------------------------------------------------------------------------------- 1 | fix-makefile-glauth-ui-light 2 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | DISTRIBUTION = "static" 4 | VERSION = $(shell git describe --abbrev=0 --tags | sed s/v//) 5 | PACKAGEVERSION = $(VERSION)-0~$(DISTRIBUTION)0 6 | 7 | %: 8 | dh $@ --with quilt 9 | 10 | override_dh_auto_build: 11 | override_dh_auto_test: 12 | override_dh_installdocs: 13 | override_dh_auto_install: 14 | $(MAKE) install DESTDIR=debian/glauth-ui-light 15 | 16 | override_dh_installsystemd: 17 | dh_installsystemd --name=glauth-ui-light 18 | 19 | override_dh_gencontrol: 20 | dh_gencontrol -- -v$(PACKAGEVERSION) 21 | 22 | override_dh_clean: 23 | # protect source .orig files 24 | dh_clean -X _sample-simple.cfg.orig 25 | 26 | # to keep logs in /var/log 27 | override_dh_install: 28 | dh_install #calls default *.install and *.dirs installation 29 | install -d -o glauth -g glauth $(CURDIR)/debian/glauth-ui-light/var/log/glauth-ui 30 | override_dh_fixperms: 31 | dh_fixperms --exclude glauth-ui 32 | 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module glauth-ui-light 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/gin-contrib/secure v0.0.1 7 | github.com/gin-contrib/sessions v0.0.5 8 | github.com/gin-contrib/static v0.0.1 9 | github.com/gin-gonic/gin v1.8.1 10 | github.com/google/go-cmp v0.5.8 11 | github.com/gorilla/securecookie v1.1.1 12 | github.com/hydronica/toml v0.5.0 13 | github.com/kataras/i18n v0.0.6 14 | github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible 15 | github.com/pquerna/otp v1.3.0 16 | github.com/sirupsen/logrus v1.8.1 17 | github.com/spf13/pflag v1.0.5 18 | github.com/stretchr/testify v1.7.5 19 | github.com/t-tomalak/logrus-easy-formatter v0.0.0-20190827215021-c074f06c5816 20 | github.com/ulule/limiter v2.2.2+incompatible 21 | github.com/utrack/gin-csrf v0.0.0-20190424104817-40fb8d2c8fca 22 | github.com/wagslane/go-password-validator v0.3.0 23 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d 24 | ) 25 | 26 | require ( 27 | github.com/BurntSushi/toml v0.3.1 // indirect 28 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect 29 | github.com/davecgh/go-spew v1.1.1 // indirect 30 | github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect 31 | github.com/gin-contrib/sse v0.1.0 // indirect 32 | github.com/go-playground/locales v0.14.0 // indirect 33 | github.com/go-playground/universal-translator v0.18.0 // indirect 34 | github.com/go-playground/validator/v10 v10.10.0 // indirect 35 | github.com/goccy/go-json v0.9.7 // indirect 36 | github.com/gorilla/context v1.1.1 // indirect 37 | github.com/gorilla/sessions v1.2.1 // indirect 38 | github.com/jonboulle/clockwork v0.3.0 // indirect 39 | github.com/json-iterator/go v1.1.12 // indirect 40 | github.com/leodido/go-urn v1.2.1 // indirect 41 | github.com/lestrrat-go/strftime v1.0.6 // indirect 42 | github.com/mattn/go-isatty v0.0.14 // indirect 43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 44 | github.com/modern-go/reflect2 v1.0.2 // indirect 45 | github.com/pelletier/go-toml/v2 v2.0.1 // indirect 46 | github.com/pkg/errors v0.9.1 // indirect 47 | github.com/pmezard/go-difflib v1.0.0 // indirect 48 | github.com/smartystreets/goconvey v1.7.2 // indirect 49 | github.com/ugorji/go/codec v1.2.7 // indirect 50 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect 51 | golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f // indirect 52 | golang.org/x/text v0.3.6 // indirect 53 | google.golang.org/protobuf v1.28.0 // indirect 54 | gopkg.in/ini.v1 v1.61.0 // indirect 55 | gopkg.in/yaml.v2 v2.4.0 // indirect 56 | gopkg.in/yaml.v3 v3.0.1 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /handlers/_sample-simple.cfg.orig: -------------------------------------------------------------------------------- 1 | ################# 2 | # glauth.conf 3 | 4 | ################# 5 | # General configuration. 6 | debug = true 7 | # syslog = true 8 | # 9 | # Enable hot-reload of configuration on changes 10 | # - does NOT work [ldap], [ldaps], [backend] or [api] sections 11 | # watchconfig = true 12 | 13 | ################# 14 | # yubikeyclientid = "yubi-api-clientid" 15 | # yubikeysecret = "yubi-api-secret" 16 | 17 | ################# 18 | # Server configuration. 19 | [ldap] 20 | enabled = true 21 | # run on a non privileged port 22 | listen = "0.0.0.0:3893" 23 | 24 | [ldaps] 25 | # to enable ldaps genrerate a certificate, eg. with: 26 | # openssl req -x509 -newkey rsa:4096 -keyout glauth.key -out glauth.crt -days 365 -nodes -subj '/CN=`hostname`' 27 | enabled = false 28 | listen = "0.0.0.0:3894" 29 | cert = "glauth.crt" 30 | key = "glauth.key" 31 | 32 | ################# 33 | # The backend section controls the data store. 34 | [backend] 35 | datastore = "config" 36 | baseDN = "dc=glauth,dc=com" 37 | nameformat = "cn" 38 | groupformat = "ou" 39 | 40 | ## Configure dn format to use structures like 41 | ## "uid=serviceuser,cn=svcaccts,$BASEDN" instead of "cn=serviceuser,ou=svcaccts,$BASEDN" 42 | ## to help ease migrations from other LDAP systems 43 | # nameformat = "uid" 44 | # groupformat = "cn" 45 | 46 | ## Configure ssh-key attribute name, default is 'sshPublicKey' 47 | # sshkeyattr = "ipaSshPubKey" 48 | 49 | [behaviors] 50 | # Ignore all capabilities restrictions, for instance allowing every user to perform a search 51 | IgnoreCapabilities = false 52 | # Enable a "fail2ban" type backoff mechanism temporarily banning repeated failed login attempts 53 | LimitFailedBinds = true 54 | # How many failed login attempts are allowed before a ban is imposed 55 | NumberOfFailedBinds = 3 56 | # How long (in seconds) is the window for failed login attempts 57 | PeriodOfFailedBinds = 10 58 | # How long (in seconds) is the ban duration 59 | BlockFailedBindsFor = 60 60 | # Clean learnt IP addresses every N seconds 61 | PruneSourceTableEvery = 600 62 | # Clean learnt IP addresses not seen in N seconds 63 | PruneSourcesOlderThan = 600 64 | 65 | ################# 66 | # Enable and configure the optional REST API here. 67 | [api] 68 | enabled = true 69 | internals = true # debug application performance 70 | tls = false # enable TLS for production!! 71 | listen = "0.0.0.0:5555" 72 | cert = "cert.pem" 73 | key = "key.pem" 74 | 75 | ################# 76 | # The users section contains a hardcoded list of valid users. 77 | # to create a passSHA256: echo -n "mysecret" | openssl dgst -sha256 78 | [[users]] 79 | name = "hackers" 80 | uidnumber = 5001 81 | primarygroup = 5501 82 | passsha256 = "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a" # dogood 83 | [[users.customattributes]] 84 | employeetype = ["Intern", "Temp"] 85 | employeenumber = [12345, 54321] 86 | [[users.capabilities]] 87 | action = "search" 88 | object = "ou=superheros,dc=glauth,dc=com" 89 | 90 | # This user record shows all of the possible fields available 91 | [[users]] 92 | name = "johndoe" 93 | givenname="John" 94 | sn="Doe" 95 | mail = "jdoe@example.com" 96 | uidnumber = 5002 97 | primarygroup = 5501 98 | loginShell = "/bin/sh" 99 | homeDir = "/root" 100 | passsha256 = "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a" # dogood 101 | sshkeys = ["ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAQEA3UKCEllO2IZXgqNygiVb+dDLJJwVw3AJwV34t2jzR+/tUNVeJ9XddKpYQektNHsFmY93lJw5QDSbeH/mAC4KPoUM47EriINKEelRbyG4hC/ko/e2JWqEclPS9LP7GtqGmscXXo4JFkqnKw4TIRD52XI9n1syYM9Y8rJ88fjC/Lpn+01AB0paLVIfppJU35t0Ho9doHAEfEvcQA6tcm7FLJUvklAxc8WUbdziczbRV40KzDroIkXAZRjX7vXXhh/p7XBYnA0GO8oTa2VY4dTQSeDAUJSUxbzevbL0ll9Gi1uYaTDQyE5gbn2NfJSqq0OYA+3eyGtIVjFYZgi+txSuhw== rsa-key-20160209"] 102 | passappsha256 = [ 103 | "c32255dbf6fd6b64883ec8801f793bccfa2a860f2b1ae1315cd95cdac1338efa", # TestAppPw1 104 | "c9853d5f2599e90497e9f8cc671bd2022b0fb5d1bd7cfff92f079e8f8f02b8d3", # TestAppPw2 105 | "4939efa7c87095dacb5e7e8b8cfb3a660fa1f5edcc9108f6d7ec20ea4d6b3a88", # TestAppPw3 106 | ] 107 | 108 | [[users]] 109 | name = "serviceuser" 110 | mail = "serviceuser@example.com" 111 | uidnumber = 5003 112 | primarygroup = 5502 113 | passsha256 = "652c7dc687d98c9889304ed2e408c74b611e86a40caa51c4b43f1dd5913c5cd0" # mysecret 114 | [[users.capabilities]] 115 | action = "search" 116 | object = "*" 117 | 118 | # Test user showing 2 factor auth authentication 119 | [[users]] 120 | name = "otpuser" 121 | uidnumber = 5004 122 | primarygroup = 5501 123 | passsha256 = "652c7dc687d98c9889304ed2e408c74b611e86a40caa51c4b43f1dd5913c5cd0" # mysecret 124 | otpsecret = "3hnvnk4ycv44glzigd6s25j4dougs3rk" 125 | yubikey = "vvjrcfalhlaa" 126 | [[users.capabilities]] 127 | action = "search" 128 | object = "ou=superheros,dc=glauth,dc=com" 129 | 130 | [[users]] 131 | name = "uberhackers" 132 | uidnumber = 5005 133 | primarygroup = 5501 134 | # bcrypt format: hex($2y$2^$$) 135 | passappbcrypt = [ 136 | "243261243130244B62463462656F7265504F762E794F324957746D656541326B4B46596275674A79336A476845764B616D65446169784E41384F4432" # dogood 137 | ] 138 | # uncomment and comment out above array to test password with otp code 139 | # passappbcrypt = "243261243130244B62463462656F7265504F762E794F324957746D656541326B4B46596275674A79336A476845764B616D65446169784E41384F4432" # dogood 140 | otpsecret = "3hnvnk4ycv44glzigd6s25j4dougs3rk" 141 | othergroups = [5502,5503] 142 | 143 | 144 | ################# 145 | # The groups section contains a hardcoded list of valid users. 146 | [[groups]] 147 | name = "superheros" 148 | gidnumber = 5501 149 | 150 | [[groups]] 151 | name = "svcaccts" 152 | gidnumber = 5502 153 | 154 | [[groups]] 155 | name = "vpn" 156 | gidnumber = 5503 157 | includegroups = [ 5501 ] 158 | 159 | -------------------------------------------------------------------------------- /handlers/db.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | . "glauth-ui-light/config" 9 | . "glauth-ui-light/helpers" 10 | ) 11 | 12 | func CancelChanges(c *gin.Context) { 13 | cfg := c.MustGet("Cfg").(WebConfig) 14 | lang := cfg.Locale.Lang 15 | 16 | if !isAdminAccess(c, "CancelChanges", "-") { 17 | return 18 | } 19 | 20 | if Lock != 0 { 21 | DataRead, _, err := ReadDB(&cfg) 22 | if err == nil { 23 | Data = DataRead 24 | Lock = 0 25 | SetFlashCookie(c, "success", Tr(lang, "Changes canceled")) 26 | Log.Info(fmt.Sprintf("%s -- [%s] changes canceled", c.ClientIP(), c.MustGet("Login").(string))) 27 | } else { 28 | SetFlashCookie(c, "warning", err.Error()) 29 | } 30 | } else { 31 | SetFlashCookie(c, "warning", Tr(lang, "Nothing to cancel")) 32 | } 33 | 34 | c.Redirect(302, "/auth/crud/user") 35 | } 36 | 37 | func SaveChanges(c *gin.Context) { 38 | cfg := c.MustGet("Cfg").(WebConfig) 39 | lang := cfg.Locale.Lang 40 | 41 | if !isAdminAccess(c, "SaveChanges", "-") { 42 | return 43 | } 44 | 45 | if Lock != 0 { 46 | username := c.MustGet("Login").(string) 47 | err := WriteDB(&cfg, Data, username) 48 | if err == nil { 49 | Lock = 0 50 | SetFlashCookie(c, "success", Tr(lang, "Changes saved")) 51 | Log.Info(fmt.Sprintf("%s -- [%s] changes saved", c.ClientIP(), username)) 52 | } else { 53 | SetFlashCookie(c, "warning", err.Error()) 54 | } 55 | } else { 56 | SetFlashCookie(c, "warning", Tr(lang, "Nothing to save")) 57 | } 58 | 59 | c.Redirect(302, "/auth/crud/user") 60 | } 61 | -------------------------------------------------------------------------------- /handlers/db_test.go: -------------------------------------------------------------------------------- 1 | //nolint 2 | package handlers 3 | 4 | import ( 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/stretchr/testify/assert" 11 | 12 | . "glauth-ui-light/config" 13 | "glauth-ui-light/helpers" 14 | ) 15 | 16 | func TestDB(t *testing.T) { 17 | // defer deleteFile(config.DBname) 18 | 19 | cfg := WebConfig{ 20 | DBfile: "_sample-simple.cfg", 21 | Locale: Locale{ 22 | Lang: "en", 23 | Path: "../locales/", 24 | }, 25 | Debug: true, 26 | Tests: false, 27 | CfgUsers: CfgUsers{ 28 | Start: 5000, 29 | GIDAdmin: 6501, 30 | GIDcanChgPass: 6500, 31 | }, 32 | PassPolicy: PassPolicy{ 33 | AllowReadSSHA256: true, 34 | }, 35 | } 36 | copyTmpFile(cfg.DBfile+".orig", cfg.DBfile) 37 | 38 | defer clean(cfg.DBfile) 39 | 40 | initUsersValues() 41 | gin.SetMode(gin.TestMode) 42 | router := InitRouterTest(cfg) 43 | 44 | Url := "/auth/crud" 45 | router.Use(SetUserTest("user1", "5000", "admin")) 46 | router.GET(Url+"/user", UserList) 47 | router.GET(Url+"/reload", CancelChanges) 48 | router.GET(Url+"/save", SaveChanges) 49 | 50 | // reload 51 | fmt.Println("= Reload") 52 | resp, resurl := testAccessSimple(t, router, "GET", Url+"/reload") 53 | assert.Equal(t, 200, resp.Code, "http GET reload success") 54 | assert.Equal(t, Url+"/user", resurl, "http GET reload success") 55 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "Nothing to cancel"), "Nothing to cancel") 56 | 57 | Lock = 1 58 | resp, resurl = testAccessSimple(t, router, "GET", Url+"/reload") 59 | assert.Equal(t, 200, resp.Code, "http GET reload success") 60 | assert.Equal(t, Url+"/user", resurl, "http GET reload success") 61 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "Changes canceled"), "db reloaded") 62 | assert.Equal(t, 0, Lock, "No more Lock") 63 | assert.Equal(t, 5, len(Data.Users), "5 users in db") 64 | assert.Equal(t, 3, len(Data.Groups), "3 groups in db") 65 | 66 | resp, resurl = testAccessSimple(t, router, "GET", Url+"/save") 67 | assert.Equal(t, 200, resp.Code, "http GET reload success") 68 | assert.Equal(t, Url+"/user", resurl, "http GET reload success") 69 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "Nothing to save"), "Nothing to save") 70 | 71 | Lock = 1 72 | resp, resurl = testAccessSimple(t, router, "GET", Url+"/save") 73 | assert.Equal(t, 200, resp.Code, "http GET reload success") 74 | assert.Equal(t, Url+"/user", resurl, "http GET reload success") 75 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "Changes saved"), "Changes saved") 76 | assert.Equal(t, 0, Lock, "No more Lock") 77 | 78 | _, head, _ := helpers.ReadDB(&cfg) 79 | assert.Equal(t, true, strings.Contains(head[0], "# Updated by user1 on "), "Changed by saved on first line") 80 | 81 | // Test errors 82 | cfg.DBfile = "_badfile.cfg" 83 | rr := InitRouterTest(cfg) 84 | rr.Use(SetUserTest("user1", "5000", "admin")) 85 | rr.GET(Url+"/user", UserList) 86 | rr.GET(Url+"/reload", CancelChanges) 87 | rr.GET(Url+"/save", SaveChanges) 88 | 89 | Lock = 1 90 | resp, resurl = testAccessSimple(t, rr, "GET", Url+"/reload") 91 | assert.Equal(t, 200, resp.Code, "http GET reload success") 92 | assert.Equal(t, Url+"/user", resurl, "http GET reload failed") 93 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "Non-existent config path: _badfile.cfg"), "db reloaded") 94 | assert.Equal(t, 1, Lock, "always Lock") 95 | 96 | r := InitRouterTest(cfg) 97 | 98 | r.GET("/auth/logout", LogoutHandler) 99 | r.Use(SetUserTest("user1", "5000", "user")) 100 | r.GET(Url+"/user", UserList) 101 | r.GET(Url+"/reload", CancelChanges) 102 | r.GET(Url+"/save", SaveChanges) 103 | 104 | Lock = 1 105 | resp, resurl = testAccessSimple(t, r, "GET", Url+"/reload") 106 | assert.Equal(t, 302, resp.Code, "http GET reload failed for user") 107 | assert.Equal(t, "/auth/logout", resurl, "http GET redirect to logout") 108 | 109 | resp, resurl = testAccessSimple(t, r, "GET", Url+"/save") 110 | assert.Equal(t, 302, resp.Code, "http GET save failed for user") 111 | assert.Equal(t, "/auth/logout", resurl, "http GET redirect to logout") 112 | //fmt.Printf("%+v\n",resp) 113 | } 114 | -------------------------------------------------------------------------------- /handlers/global.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/sirupsen/logrus" 9 | 10 | . "glauth-ui-light/config" 11 | "glauth-ui-light/helpers" 12 | ) 13 | 14 | var ( 15 | Data Ctmp 16 | Lock int // number of waiting changes in memory 17 | Version = "dev" // will be set on build 18 | ) 19 | 20 | var Log logrus.Logger 21 | 22 | func isAdminAccess(c *gin.Context, ressource string, id string) bool { 23 | login := c.MustGet("Login").(string) 24 | loginid := c.MustGet("LoginID").(string) 25 | role := c.MustGet("Role").(string) 26 | // admin access 27 | if role != "admin" { 28 | Log.Info(fmt.Sprintf("-- [%s] (%s) denied admin access to %s : %s", login, loginid, ressource, id)) 29 | c.Redirect(302, "/auth/logout") 30 | c.Abort() 31 | return false 32 | } 33 | return true 34 | } 35 | 36 | func isSelfAccess(c *gin.Context, ressource string, id string) bool { 37 | login := c.MustGet("Login").(string) 38 | loginid := c.MustGet("LoginID").(string) 39 | role := c.MustGet("Role").(string) 40 | 41 | // Self access 42 | if role != "admin" && loginid != id { 43 | Log.Info(fmt.Sprintf("-- [%s] (%s) denied self access to %s : %s", login, loginid, ressource, id)) 44 | c.Redirect(302, "/auth/logout") 45 | c.Abort() 46 | return false 47 | } 48 | return true 49 | } 50 | 51 | func render(c *gin.Context, data gin.H, templateName string) { 52 | // Set user 53 | role, _ := c.Get("Role") 54 | data["userName"], data["userId"] = helpers.GetUserID(c) 55 | if role != nil && role.(string) == "admin" { 56 | data["roleAdmin"] = true 57 | } 58 | 59 | // Set CSRF token in forms 60 | data["Csrf"], _ = c.Get("Csrf") 61 | 62 | // Set view elements 63 | data["lock"] = Lock 64 | data["version"] = Version 65 | data["appname"], _ = c.Get("AppName") 66 | data["MaskOTP"], _ = c.Get("MaskOTP") 67 | data["DefaultHomedir"], _ = c.Get("DefaultHomedir") 68 | data["DefaultLoginShell"], _ = c.Get("DefaultLoginShell") 69 | 70 | canChgPass, _ := c.Get("CanChgPass") 71 | if canChgPass != nil { 72 | data["canChgPass"] = canChgPass.(bool) 73 | } 74 | 75 | useOtp, _ := c.Get("UseOtp") 76 | if useOtp != nil { 77 | data["useOtp"] = useOtp.(bool) 78 | } 79 | 80 | data["groupsinfo"] = GetSpecialGroups(c) 81 | 82 | if data["success"] == nil { 83 | data["success"] = helpers.GetFlashCookie(c, "success") 84 | } 85 | if data["warning"] == nil { 86 | data["warning"] = helpers.GetFlashCookie(c, "warning") 87 | } 88 | if data["error"] == nil { 89 | data["error"] = helpers.GetFlashCookie(c, "error") 90 | } 91 | 92 | c.HTML(http.StatusOK, templateName, data) 93 | 94 | /*switch c.Request.Header.Get("Accept") { 95 | case "application/json": 96 | // Respond with JSON 97 | c.JSON(http.StatusOK, data["payload"]) 98 | case "application/xml": 99 | // Respond with XML 100 | c.XML(http.StatusOK, data["payload"]) 101 | default: 102 | // Respond with HTML 103 | c.HTML(http.StatusOK, templateName, data) 104 | }*/ 105 | } 106 | -------------------------------------------------------------------------------- /handlers/global_test.go: -------------------------------------------------------------------------------- 1 | //nolint 2 | package handlers 3 | 4 | /** 5 | Common functions for handlers tests 6 | **/ 7 | 8 | import ( 9 | "fmt" 10 | "html/template" 11 | "io/ioutil" 12 | "net/http" 13 | "net/http/httptest" 14 | "net/url" 15 | "os" 16 | "strings" 17 | "testing" 18 | 19 | "github.com/gin-gonic/gin" 20 | "github.com/kataras/i18n" 21 | "github.com/stretchr/testify/assert" 22 | 23 | "github.com/gin-contrib/sessions" 24 | "github.com/gin-contrib/sessions/cookie" 25 | 26 | . "glauth-ui-light/config" 27 | . "glauth-ui-light/helpers" 28 | ) 29 | 30 | // tools 31 | 32 | func deleteFile(file string) { 33 | // delete file 34 | var err = os.Remove(file) 35 | if err != nil { 36 | fmt.Println(err.Error()) 37 | //os.Exit(0) 38 | } 39 | } 40 | 41 | func copyTmpFile(source, dest string) { 42 | content, _ := ioutil.ReadFile(source) 43 | ioutil.WriteFile(dest, content, 0640) 44 | } 45 | 46 | func resetData() { 47 | Data.Users = []User{} 48 | Data.Groups = []Group{} 49 | } 50 | 51 | func clean(file string) { 52 | resetData() 53 | deleteFile(file + ".1") 54 | deleteFile(file + ".2") 55 | copyTmpFile(file+".orig", file) 56 | deleteFile(file) 57 | } 58 | 59 | // testAccessSimple : access without cookie 60 | func testAccessSimple(t *testing.T, router *gin.Engine, method string, url string) (*httptest.ResponseRecorder, string) { 61 | req, _ := http.NewRequest(method, url, nil) 62 | resp := httptest.NewRecorder() 63 | router.ServeHTTP(resp, req) 64 | 65 | if resp.Code == 302 { 66 | location, _ := resp.Result().Location() 67 | fmt.Printf("=> Redirect to: %s\n", location.String()) 68 | url = location.String() 69 | cookie := resp.Result().Cookies() 70 | req, _ = http.NewRequest("GET", url, nil) 71 | if len(cookie) != 0 { 72 | for _, c := range cookie { 73 | req.Header.Add("Cookie", c.String()) 74 | } 75 | } 76 | resp = httptest.NewRecorder() 77 | router.ServeHTTP(resp, req) 78 | } 79 | return resp, url 80 | } 81 | 82 | // testAccess with cookie from login 83 | func testAccess(t *testing.T, router *gin.Engine, method string, testurl string) (*httptest.ResponseRecorder, string) { 84 | req, _ := http.NewRequest(method, testurl, nil) 85 | resp := httptest.NewRecorder() 86 | router.ServeHTTP(resp, req) 87 | 88 | if resp.Code == 302 { 89 | location, _ := resp.Result().Location() 90 | fmt.Printf("=> Redirect to: %s\n", location.String()) 91 | testurl = location.String() 92 | req, _ = http.NewRequest("GET", testurl, nil) 93 | resp = httptest.NewRecorder() 94 | router.ServeHTTP(resp, req) 95 | } 96 | return resp, testurl 97 | } 98 | 99 | // testLogin 100 | func testLogin(t *testing.T, r *gin.Engine, login string, pass string, s []*http.Cookie) (*httptest.ResponseRecorder, []*http.Cookie) { 101 | form := url.Values{} 102 | form.Add("username", login) 103 | form.Add("password", pass) 104 | req, err := http.NewRequest("POST", "/auth/login", strings.NewReader(form.Encode())) 105 | if len(s) != 0 { 106 | for _, c := range s { 107 | req.Header.Add("Cookie", c.String()) 108 | } 109 | } 110 | req.PostForm = form 111 | req.PostForm = form 112 | req.Header.Add("Content-Type", "application/x-www-form-Urlencoded") 113 | if err != nil { 114 | fmt.Println(err) 115 | } 116 | resp := httptest.NewRecorder() 117 | r.ServeHTTP(resp, req) 118 | // fmt.Printf("%+v\n",resp) 119 | if login == "" || pass == "" { 120 | return resp, nil 121 | } 122 | assert.Equal(t, 302, resp.Code, "http POST success redirect to Edit") 123 | newurl, _ := resp.Result().Location() 124 | fmt.Printf("=> Redirect to: %s\n", newurl.String()) 125 | req, _ = http.NewRequest("GET", newurl.String(), nil) 126 | cookie := resp.Result().Cookies() 127 | if len(cookie) != 0 { 128 | for _, c := range cookie { 129 | req.Header.Add("Cookie", c.String()) 130 | } 131 | } 132 | fmt.Printf("=> Cookie: %+v\n", cookie[0]) 133 | //fmt.Printf("=> req: %+v\n", req) 134 | resp = httptest.NewRecorder() 135 | r.ServeHTTP(resp, req) 136 | return resp, cookie 137 | } 138 | 139 | // testLogin with otp 140 | func testCode(t *testing.T, router *gin.Engine, code string, session []*http.Cookie) *httptest.ResponseRecorder { 141 | form := url.Values{} 142 | form.Add("code", code) 143 | req, err := http.NewRequest("POST", "/auth/login", strings.NewReader(form.Encode())) 144 | if len(session) != 0 { 145 | for _, c := range session { 146 | req.Header.Add("Cookie", c.String()) 147 | } 148 | } 149 | req.PostForm = form 150 | req.Header.Add("Content-Type", "application/x-www-form-Urlencoded") 151 | if err != nil { 152 | fmt.Println(err) 153 | } 154 | resp := httptest.NewRecorder() 155 | router.ServeHTTP(resp, req) 156 | // fmt.Printf("%+v\n",resp) 157 | if code == "" { 158 | return resp 159 | } 160 | assert.Equal(t, 302, resp.Code, "http POST success redirect to Edit") 161 | newurl, _ := resp.Result().Location() 162 | fmt.Printf("=> Redirect to: %s\n", newurl.String()) 163 | cookie := resp.Result().Cookies() 164 | fmt.Printf("=> Cookie: %+v\n", cookie) 165 | return resp 166 | } 167 | 168 | // mock routes function 169 | 170 | func setConfigTest(cfg WebConfig) gin.HandlerFunc { 171 | return func(c *gin.Context) { 172 | c.Set("Cfg", cfg) 173 | // set values when auth is bypassed 174 | c.Set("AppName", cfg.AppName) 175 | c.Set("MaskOTP", cfg.MaskOTP) 176 | c.Set("DefaultHomedir", cfg.DefaultHomedir) 177 | c.Set("DefaultLoginShell", cfg.DefaultLoginShell) 178 | c.Next() 179 | } 180 | } 181 | 182 | func SetUserTest(login string, loginID string, role string) gin.HandlerFunc { 183 | return func(c *gin.Context) { 184 | c.Set("Login", login) 185 | c.Set("LoginID", loginID) 186 | c.Set("Role", role) 187 | c.Next() 188 | } 189 | } 190 | 191 | func InitRouterTest(cfg WebConfig) *gin.Engine { 192 | r := gin.New() 193 | r.Use(gin.Logger()) 194 | r.Use(gin.Recovery()) 195 | basePath := cfg.Locale.Path 196 | 197 | r.Static("/css", basePath+"/web/assets/css") 198 | r.Static("/fonts", basePath+"/web/assets/fonts") 199 | r.Static("/js", basePath+"/web/assets/js") 200 | 201 | var err error 202 | I18n, err = i18n.New(i18n.Glob(basePath+"/*/*"), cfg.Locale.Langs...) 203 | if err != nil { 204 | panic(err) 205 | } 206 | 207 | translateLangFunc := func(x string) string { return Tr(cfg.Locale.Lang, x) } 208 | 209 | r.SetFuncMap(template.FuncMap{ 210 | "tr": translateLangFunc, 211 | }) 212 | r.LoadHTMLGlob("../routes/web/templates/**/*.tmpl") 213 | 214 | store := cookie.NewStore([]byte("somesecret")) 215 | store.Options(sessions.Options{ 216 | //Domain: "localhost", 217 | SameSite: http.SameSiteStrictMode, 218 | }) 219 | r.Use(sessions.Sessions("session", store)) 220 | 221 | r.Use(setConfigTest(cfg)) 222 | return r 223 | 224 | } 225 | 226 | // some default test values 227 | 228 | func initUsersValues() { 229 | v1 := User{ 230 | Name: "user1", 231 | UIDNumber: 5000, 232 | PrimaryGroup: 6501, 233 | PassSHA256: "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a", 234 | OTPSecret: "3hnvnk4ycv44glzigd6s25j4dougs3rk", 235 | } 236 | Data.Users = append(Data.Users, v1) 237 | v2 := User{ 238 | Name: "user2", 239 | UIDNumber: 5001, 240 | PrimaryGroup: 6504, 241 | OtherGroups: []int{6501, 6503}, 242 | } 243 | Data.Users = append(Data.Users, v2) 244 | v3 := User{ 245 | Name: "serviceapp", 246 | UIDNumber: 5002, 247 | PrimaryGroup: 6502, 248 | PassSHA256: "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a", 249 | } 250 | Data.Users = append(Data.Users, v3) 251 | g1 := Group{ 252 | Name: "group1", 253 | GIDNumber: 6502, 254 | } 255 | Data.Groups = append(Data.Groups, g1) 256 | g2 := Group{ 257 | Name: "group2", 258 | GIDNumber: 6503, 259 | } 260 | Data.Groups = append(Data.Groups, g2) 261 | } 262 | -------------------------------------------------------------------------------- /handlers/groups.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | . "glauth-ui-light/config" 11 | . "glauth-ui-light/helpers" 12 | ) 13 | 14 | // Validate entries 15 | 16 | // var rxName = regexp.MustCompile("^[a-z0-9]+$") 17 | 18 | type GroupForm struct { 19 | GIDNumber int 20 | Name string 21 | Errors map[string]string 22 | Lang string 23 | } 24 | 25 | func (msg *GroupForm) Validate() bool { 26 | lang := msg.Lang 27 | msg.Errors = make(map[string]string) 28 | 29 | n := msg.Name 30 | matchAscii := rxName.MatchString(n) 31 | switch { 32 | case strings.TrimSpace(n) == "": 33 | msg.Errors["Name"] = Tr(lang, "Mandatory") 34 | case len(n) < 2: 35 | msg.Errors["Name"] = Tr(lang, "Too short") 36 | case len(n) > 16: 37 | msg.Errors["Name"] = Tr(lang, "Too long") 38 | case !matchAscii: 39 | msg.Errors["Name"] = Tr(lang, "Bad character") 40 | } 41 | for k := range Data.Groups { 42 | if Data.Groups[k].Name == n && Data.Groups[k].GIDNumber != msg.GIDNumber { 43 | msg.Errors["Name"] = Tr(lang, "Name already used") 44 | break 45 | } 46 | } 47 | 48 | if msg.GIDNumber < 0 { 49 | msg.Errors["GIDNumber"] = Tr(lang, "Unknown group") 50 | } 51 | 52 | return len(msg.Errors) == 0 53 | } 54 | 55 | // Helpers 56 | 57 | func ctlGroupExist(c *gin.Context, lang string, id string) int { 58 | k := GetGroupKey(id) 59 | if k < 0 { 60 | render(c, gin.H{"title": Tr(lang, "Error"), "currentPage": "group", "error": Tr(lang, "Unknown group")}, "home/error.tmpl") 61 | return -1 62 | } 63 | return k 64 | } 65 | 66 | func GetGroupKey(id string) int { 67 | i := -1 68 | intId, _ := strconv.Atoi(id) 69 | for k := range Data.Groups { 70 | if Data.Groups[k].GIDNumber == intId { 71 | i = k 72 | break 73 | } 74 | } 75 | return i 76 | } 77 | 78 | func GetGroupByID(id int) (Group, error) { 79 | for k := range Data.Groups { 80 | if Data.Groups[k].GIDNumber == id { 81 | return Data.Groups[k], nil 82 | } 83 | } 84 | return Group{}, fmt.Errorf("unknown group") 85 | } 86 | 87 | type SpecialGroups struct { 88 | Admins string 89 | Users string 90 | OTP string 91 | } 92 | 93 | func GetSpecialGroups(c *gin.Context) SpecialGroups { 94 | cfg := c.MustGet("Cfg").(WebConfig) 95 | s := SpecialGroups{} 96 | g, err := GetGroupByID(cfg.CfgUsers.GIDAdmin) 97 | if err == nil { 98 | s.Admins = g.Name 99 | } 100 | g, err = GetGroupByID(cfg.CfgUsers.GIDcanChgPass) 101 | if err == nil { 102 | s.Users = g.Name 103 | } 104 | g, err = GetGroupByID(cfg.CfgUsers.GIDuseOtp) 105 | if err == nil { 106 | s.OTP = g.Name 107 | } 108 | 109 | return s 110 | } 111 | 112 | /*func GetGroupByName(name string) (Group, error) { 113 | for _, v := range Data.Groups { 114 | if v.Name == name { 115 | return v, nil 116 | } 117 | } 118 | return Group{}, fmt.Errorf("unknown group") 119 | }*/ 120 | 121 | func contains(s []int, e int) bool { 122 | for _, a := range s { 123 | if a == e { 124 | return true 125 | } 126 | } 127 | return false 128 | } 129 | 130 | func isGroupEmpty(id int) bool { 131 | for k := range Data.Users { 132 | if Data.Users[k].PrimaryGroup == id { 133 | return false 134 | } 135 | if contains(Data.Users[k].OtherGroups, id) { 136 | return false 137 | } 138 | } 139 | return true 140 | } 141 | 142 | // Handlers 143 | 144 | func GroupList(c *gin.Context) { 145 | cfg := c.MustGet("Cfg").(WebConfig) 146 | lang := cfg.Locale.Lang 147 | 148 | if !isAdminAccess(c, "GroupList", "-") { 149 | return 150 | } 151 | 152 | hg := make(map[int]string) 153 | for k := range Data.Groups { 154 | hg[Data.Groups[k].GIDNumber] = Data.Groups[k].Name 155 | } 156 | render(c, gin.H{"title": Tr(lang, "Groups page"), "currentPage": "group", "groupdata": Data.Groups, "hashgroups": hg}, "group/list.tmpl") 157 | } 158 | 159 | func GroupEdit(c *gin.Context) { 160 | cfg := c.MustGet("Cfg").(WebConfig) 161 | lang := cfg.Locale.Lang 162 | id := c.Params.ByName("id") 163 | 164 | if !isAdminAccess(c, "GroupEdit", id) { 165 | return 166 | } 167 | 168 | k := ctlGroupExist(c, lang, id) 169 | if k < 0 { 170 | return 171 | } 172 | 173 | u := Data.Groups[k] 174 | groupf := GroupForm{ 175 | GIDNumber: u.GIDNumber, 176 | Name: u.Name, 177 | Lang: lang, 178 | } 179 | 180 | render(c, gin.H{"title": Tr(lang, "Edit group"), "currentPage": "group", "u": groupf, "groupdata": Data.Groups}, "group/edit.tmpl") 181 | } 182 | 183 | func GroupUpdate(c *gin.Context) { 184 | cfg := c.MustGet("Cfg").(WebConfig) 185 | lang := cfg.Locale.Lang 186 | id := c.Params.ByName("id") 187 | 188 | if !isAdminAccess(c, "GroupUpdate", id) { 189 | return 190 | } 191 | 192 | k := ctlGroupExist(c, lang, id) 193 | if k < 0 { 194 | return 195 | } 196 | 197 | // Bind form to struct 198 | groupf := &GroupForm{ 199 | GIDNumber: Data.Groups[k].GIDNumber, 200 | Name: c.PostForm("inputName"), 201 | Lang: lang, 202 | } 203 | // fmt.Printf("%+v\n", groupf) 204 | 205 | // Validate entries 206 | if !groupf.Validate() { 207 | render(c, gin.H{"title": Tr(lang, "Edit group"), "currentPage": "group", "u": groupf, "groupdata": Data.Groups}, "group/edit.tmpl") 208 | return 209 | } 210 | 211 | // Update Data 212 | (&Data.Groups[k]).Name = groupf.Name 213 | 214 | Lock++ 215 | 216 | Log.Info(fmt.Sprintf("%s -- %s updated by %s", c.ClientIP(), groupf.Name, c.MustGet("Login").(string))) 217 | 218 | render(c, gin.H{ 219 | "title": Tr(lang, "Edit group"), 220 | "currentPage": "group", 221 | "success": "«" + groupf.Name + "» updated", 222 | "u": groupf, 223 | "groupdata": Data.Groups}, 224 | "group/edit.tmpl") 225 | } 226 | 227 | func GroupAdd(c *gin.Context) { 228 | cfg := c.MustGet("Cfg").(WebConfig) 229 | lang := cfg.Locale.Lang 230 | 231 | if !isAdminAccess(c, "GroupAdd", "-") { 232 | return 233 | } 234 | 235 | render(c, gin.H{"title": Tr(lang, "Add group"), "currentPage": "group"}, "group/create.tmpl") 236 | } 237 | 238 | func GroupCreate(c *gin.Context) { 239 | cfg := c.MustGet("Cfg").(WebConfig) 240 | lang := cfg.Locale.Lang 241 | 242 | if !isAdminAccess(c, "GroupCreate", "-") { 243 | return 244 | } 245 | 246 | // Bind form to struct 247 | groupf := &GroupForm{ 248 | Name: c.PostForm("inputName"), 249 | Lang: lang, 250 | } 251 | // Validate entries 252 | if !groupf.Validate() { 253 | render(c, gin.H{"title": Tr(lang, "Add group"), "currentPage": "group", "u": groupf, "groupdata": Data.Groups}, "group/create.tmpl") 254 | return 255 | } 256 | 257 | // Create new id 258 | nextID := cfg.CfgGroups.Start - 1 // start uidnumber via config 259 | for k := range Data.Groups { 260 | if Data.Groups[k].GIDNumber >= nextID { 261 | nextID = Data.Groups[k].GIDNumber 262 | } 263 | } 264 | groupf.GIDNumber = nextID + 1 265 | // Add Group to Data 266 | newGroup := Group{GIDNumber: groupf.GIDNumber, Name: groupf.Name} 267 | Data.Groups = append(Data.Groups, newGroup) 268 | 269 | Lock++ 270 | 271 | Log.Info(fmt.Sprintf("%s -- %s created by %s", c.ClientIP(), newGroup.Name, c.MustGet("Login").(string))) 272 | 273 | SetFlashCookie(c, "success", "«"+newGroup.Name+"» added") 274 | c.Redirect(302, fmt.Sprintf("/auth/crud/group/%d", newGroup.GIDNumber)) 275 | } 276 | 277 | func GroupDel(c *gin.Context) { 278 | cfg := c.MustGet("Cfg").(WebConfig) 279 | lang := cfg.Locale.Lang 280 | id := c.Params.ByName("id") 281 | 282 | if !isAdminAccess(c, "GroupDel", id) { 283 | return 284 | } 285 | 286 | k := ctlGroupExist(c, lang, id) 287 | if k < 0 { 288 | return 289 | } 290 | 291 | deletedGroup := Data.Groups[k] 292 | 293 | if isGroupEmpty(deletedGroup.GIDNumber) { 294 | Data.Groups = append(Data.Groups[:k], Data.Groups[k+1:]...) 295 | 296 | Lock++ 297 | 298 | Log.Info(fmt.Sprintf("%s -- %s deleted by %s", c.ClientIP(), deletedGroup.Name, c.MustGet("Login").(string))) 299 | 300 | SetFlashCookie(c, "success", "«"+deletedGroup.Name+"» deleted") 301 | } else { 302 | SetFlashCookie(c, "warning", Tr(lang, "Group must be empty before being deleted")) 303 | } 304 | c.Redirect(302, "/auth/crud/group") 305 | } 306 | -------------------------------------------------------------------------------- /handlers/groups_test.go: -------------------------------------------------------------------------------- 1 | //nolint 2 | package handlers 3 | 4 | import ( 5 | //"bytes" 6 | //"encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "regexp" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/gin-gonic/gin" 16 | "github.com/stretchr/testify/assert" 17 | 18 | . "glauth-ui-light/config" 19 | ) 20 | 21 | func TestGroupValidate(t *testing.T) { 22 | defer resetData() 23 | 24 | cfg := WebConfig{ 25 | Locale: Locale{ 26 | Lang: "en", 27 | Path: "../locales/", 28 | }, 29 | } 30 | InitRouterTest(cfg) 31 | initUsersValues() 32 | 33 | for _, s := range []string{"", "u", "va2ieYeidafee8Gi0", "uuu nn", "Aee"} { 34 | tf := GroupForm{ 35 | Name: s, 36 | Lang: cfg.Locale.Lang, 37 | } 38 | v := tf.Validate() 39 | fmt.Printf(" test Name «%s» : %s\n", s, tf.Errors["Name"]) 40 | assert.Equal(t, true, len(tf.Errors["Name"]) > 0, "set Name error") 41 | assert.Equal(t, false, v, "bad Name form: "+tf.Errors["Name"]) 42 | } 43 | 44 | groupf := GroupForm{ 45 | GIDNumber: -1, 46 | Name: "éé-- az", 47 | Lang: cfg.Locale.Lang, 48 | } 49 | v := groupf.Validate() 50 | assert.Equal(t, false, v, "unvalide group form") 51 | assert.Equal(t, "Unknown group", groupf.Errors["GIDNumber"], "unvalide group form") 52 | assert.Equal(t, "Bad character", groupf.Errors["Name"], "unvalide group form") 53 | 54 | groupf = GroupForm{ 55 | Name: "group1", 56 | Lang: cfg.Locale.Lang, 57 | } 58 | v = groupf.Validate() 59 | assert.Equal(t, false, v, "unvalide group form") 60 | assert.Equal(t, "Name already used", groupf.Errors["Name"], "unvalide group form") 61 | 62 | groupf = GroupForm{ 63 | Name: "", 64 | Lang: cfg.Locale.Lang, 65 | } 66 | v = groupf.Validate() 67 | assert.Equal(t, false, v, "unvalide group form") 68 | assert.Equal(t, "Mandatory", groupf.Errors["Name"], "unvalide group form") 69 | 70 | } 71 | 72 | func TestGroupHandlers(t *testing.T) { 73 | defer resetData() 74 | 75 | cfg := WebConfig{ 76 | DBfile: "sample-simple.cfg", 77 | Locale: Locale{ 78 | Lang: "en", 79 | Path: "../locales/", 80 | }, 81 | Debug: true, 82 | Tests: true, 83 | CfgUsers: CfgUsers{ 84 | Start: 5000, 85 | GIDAdmin: 6501, 86 | GIDcanChgPass: 6650, 87 | }, 88 | CfgGroups: CfgGroups{ 89 | Start: 6500, 90 | }, 91 | } 92 | 93 | gin.SetMode(gin.TestMode) 94 | router := InitRouterTest(cfg) 95 | 96 | var Url = "/auth/crud/group" 97 | router.GET("/auth/login", LoginHandlerForm) 98 | router.Use(SetUserTest("user1", "5000", "admin")) 99 | router.GET(Url+"/create", GroupAdd) 100 | router.POST(Url+"/create", GroupCreate) 101 | router.GET(Url, GroupList) 102 | router.GET(Url+"/:id", GroupEdit) 103 | router.POST(Url+"/del/:id", GroupDel) 104 | router.POST(Url+"/:id", GroupUpdate) 105 | 106 | //fmt.Printf("%+v\n",Data) 107 | 108 | // Add 109 | fmt.Println("= http Add Group") 110 | form := url.Values{} 111 | form.Add("inputName", "group1") 112 | req, err := http.NewRequest("POST", Url+"/create", strings.NewReader(form.Encode())) 113 | req.PostForm = form 114 | req.Header.Add("Content-Type", "application/x-www-form-Urlencoded") 115 | if err != nil { 116 | fmt.Println(err) 117 | } 118 | resp := httptest.NewRecorder() 119 | router.ServeHTTP(resp, req) 120 | assert.Equal(t, 302, resp.Code, "http POST success redirect to Edit") 121 | //fmt.Println(resp.Body) 122 | 123 | // Add second group 124 | fmt.Println("= http Add more Group") 125 | form = url.Values{} 126 | form.Add("inputName", "group2") 127 | req, err = http.NewRequest("POST", Url+"/create", strings.NewReader(form.Encode())) 128 | req.PostForm = form 129 | req.Header.Add("Content-Type", "application/x-www-form-Urlencoded") 130 | resp = httptest.NewRecorder() 131 | router.ServeHTTP(resp, req) 132 | assert.Equal(t, 302, resp.Code, "http POST success redirect to Edit") 133 | 134 | // Get all 135 | fmt.Println("= http GET all Groups") 136 | req, err = http.NewRequest("GET", Url, nil) 137 | if err != nil { 138 | fmt.Println(err) 139 | } 140 | resp = httptest.NewRecorder() 141 | router.ServeHTTP(resp, req) 142 | assert.Equal(t, 200, resp.Code, "http success") 143 | //fmt.Println(resp.Body) 144 | re := regexp.MustCompile(`href="/auth/crud/group/(\d+)">Edit`) 145 | matches := re.FindAllStringSubmatch(resp.Body.String(), -1) 146 | fmt.Printf("===\n%+v\n===\n", matches) 147 | assert.Equal(t, 2, len(matches), "2 results") 148 | 149 | // Get one 150 | fmt.Println("= http GET one Group") 151 | req, err = http.NewRequest("GET", Url+"/6501", nil) 152 | if err != nil { 153 | fmt.Println(err) 154 | } 155 | resp = httptest.NewRecorder() 156 | router.ServeHTTP(resp, req) 157 | assert.Equal(t, 200, resp.Code, "http success") 158 | //fmt.Println(resp.Body) 159 | re = regexp.MustCompile(`id="inputName" value="(.*?)" required`) 160 | matches = re.FindAllStringSubmatch(resp.Body.String(), -1) 161 | assert.Equal(t, 1, len(matches), "1 result for group") 162 | fmt.Printf("===\n%+v\n===\n", matches[0][1]) 163 | assert.Equal(t, "group2", matches[0][1], "Name group2") 164 | 165 | // Delete one 166 | fmt.Println("= http DELETE one Group") 167 | req, _ = http.NewRequest("POST", Url+"/del/6500", nil) 168 | resp = httptest.NewRecorder() 169 | router.ServeHTTP(resp, req) 170 | //fmt.Println(resp) 171 | assert.Equal(t, 302, resp.Code, "http Del success, redirect to list") 172 | 173 | req, _ = http.NewRequest("GET", Url, nil) 174 | resp = httptest.NewRecorder() 175 | router.ServeHTTP(resp, req) 176 | assert.Equal(t, 200, resp.Code, "http success") 177 | re = regexp.MustCompile(`href="/auth/crud/group/(\d+)">Edit`) 178 | matches = re.FindAllStringSubmatch(resp.Body.String(), -1) 179 | //fmt.Println(resp.Body) 180 | fmt.Printf("===\n%+v\n===\n", matches) 181 | assert.Equal(t, 1, len(matches), "1 result") 182 | 183 | // Update one 184 | fmt.Println("= http Update one Group") 185 | form = url.Values{} 186 | form.Add("inputName", "group2a") 187 | req, err = http.NewRequest("POST", Url+"/6501", strings.NewReader(form.Encode())) 188 | req.PostForm = form 189 | req.Header.Add("Content-Type", "application/x-www-form-Urlencoded") 190 | resp = httptest.NewRecorder() 191 | router.ServeHTTP(resp, req) 192 | assert.Equal(t, 200, resp.Code, "http Update success") 193 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "group2a"), "group in list") 194 | 195 | // TEST good access 196 | fmt.Println("= TEST good access") 197 | respA, resurl := testAccess(t, router, "GET", "/auth/login") 198 | assert.Equal(t, 200, respA.Code, "http GET login") 199 | assert.Equal(t, true, strings.Contains(respA.Body.String(), "

Connection

"), "print login template") 200 | 201 | respA, resurl = testAccess(t, router, "GET", Url+"/create") 202 | assert.Equal(t, 200, respA.Code, "http GET create group") 203 | assert.Equal(t, true, strings.Contains(respA.Body.String(), "Add group"), "print Add group template") 204 | 205 | // TEST errors 206 | fmt.Println("= TEST errors") 207 | respA, resurl = testAccess(t, router, "GET", Url+"/5099") 208 | assert.Equal(t, 200, respA.Code, "http GET print error unknown group") 209 | assert.Equal(t, true, strings.Contains(respA.Body.String(), "

Error

"), "print error unknown group") 210 | 211 | respA, resurl = testAccess(t, router, "POST", Url+"/5099") 212 | assert.Equal(t, 200, respA.Code, "http GET print error unknown group") 213 | assert.Equal(t, true, strings.Contains(respA.Body.String(), "

Error

"), "print error unknown group") 214 | 215 | respA, resurl = testAccess(t, router, "POST", Url+"/del/5099") 216 | assert.Equal(t, 200, respA.Code, "http GET print error unknown group") 217 | assert.Equal(t, true, strings.Contains(respA.Body.String(), "

Error

"), "print error unknown group") 218 | 219 | form = url.Values{} 220 | form.Add("inputName", "bad name<>") 221 | req, _ = http.NewRequest("POST", Url+"/create", strings.NewReader(form.Encode())) 222 | req.PostForm = form 223 | req.Header.Add("Content-Type", "application/x-www-form-Urlencoded") 224 | resp = httptest.NewRecorder() 225 | router.ServeHTTP(resp, req) 226 | assert.Equal(t, 200, resp.Code, "http POST create invalid redirect to self url") 227 | 228 | form = url.Values{} 229 | form.Add("inputName", "group2<>") 230 | req, err = http.NewRequest("POST", Url+"/6501", strings.NewReader(form.Encode())) 231 | req.PostForm = form 232 | req.Header.Add("Content-Type", "application/x-www-form-Urlencoded") 233 | resp = httptest.NewRecorder() 234 | router.ServeHTTP(resp, req) 235 | assert.Equal(t, 200, resp.Code, "http Update invalid, redirect to self url") 236 | assert.Equal(t, "group2a", Data.Groups[0].Name, "updated group2") 237 | 238 | initUsersValues() 239 | //fmt.Printf("%+v\n",Data) 240 | respA, resurl = testAccess(t, router, "POST", Url+"/del/6502") 241 | assert.Equal(t, 200, respA.Code, "http POST reject non empty group, used in PrimaryGroup ") 242 | assert.Equal(t, "/auth/crud/group", resurl, "http GET redirect to logout") 243 | assert.Equal(t, true, strings.Contains(respA.Body.String(), "href=\"/auth/crud/group/6502\">Edit"), "group in list") 244 | 245 | respA, resurl = testAccess(t, router, "POST", Url+"/del/6503") 246 | assert.Equal(t, 200, respA.Code, "http POST reject non empty group, used in OtherGroups") 247 | assert.Equal(t, "/auth/crud/group", resurl, "http GET redirect to logout") 248 | assert.Equal(t, true, strings.Contains(respA.Body.String(), "href=\"/auth/crud/group/6503\">Edit"), "group in list") 249 | //fmt.Println(respA.Body) 250 | 251 | // TEST bad access 252 | fmt.Println("= TEST bad access") 253 | Url = "/auth/crud/group" 254 | r := InitRouterTest(cfg) 255 | r.GET("/auth/logout", LogoutHandler) 256 | r.Use(SetUserTest("user1", "5000", "user")) 257 | r.GET(Url+"/create", GroupAdd) 258 | r.POST(Url+"/create", GroupCreate) 259 | r.GET(Url, GroupList) 260 | r.GET(Url+"/:id", GroupEdit) 261 | r.POST(Url+"/del/:id", GroupDel) 262 | r.POST(Url+"/:id", GroupUpdate) 263 | 264 | respA, resurl = testAccess(t, r, "GET", Url) 265 | assert.Equal(t, 302, respA.Code, "http GET reject non admin access") 266 | assert.Equal(t, "/auth/logout", resurl, "http GET redirect to logout") 267 | 268 | respA, resurl = testAccess(t, r, "GET", Url+"/create") 269 | assert.Equal(t, 302, respA.Code, "http GET reject non admin access") 270 | assert.Equal(t, "/auth/logout", resurl, "http GET redirect to logout") 271 | 272 | respA, resurl = testAccess(t, r, "POST", Url+"/create") 273 | assert.Equal(t, 302, respA.Code, "http POST reject non admin access") 274 | assert.Equal(t, "/auth/logout", resurl, "http POST redirect to logout") 275 | 276 | //fmt.Printf("%+v\n",Data) 277 | respA, resurl = testAccess(t, r, "GET", Url+"/6500") 278 | assert.Equal(t, 302, respA.Code, "http GET reject non admin access") 279 | assert.Equal(t, "/auth/logout", resurl, "http GET redirect to logout") 280 | 281 | respA, resurl = testAccess(t, r, "POST", Url+"/6500") 282 | assert.Equal(t, 302, respA.Code, "http GET reject non admin access") 283 | assert.Equal(t, "/auth/logout", resurl, "http GET redirect to logout") 284 | 285 | respA, resurl = testAccess(t, r, "POST", Url+"/del/6500") 286 | assert.Equal(t, 302, respA.Code, "http GET reject non admin access") 287 | assert.Equal(t, "/auth/logout", resurl, "http GET redirect to logout") 288 | 289 | } 290 | -------------------------------------------------------------------------------- /handlers/login.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | "glauth-ui-light/config" 9 | "glauth-ui-light/helpers" 10 | ) 11 | 12 | func LoginHandlerForm(c *gin.Context) { 13 | cfg := c.MustGet("Cfg").(config.WebConfig) 14 | userName, userId := helpers.GetUserID(c) 15 | s := helpers.GetSession(c) 16 | 17 | c.HTML(200, "home/login.tmpl", gin.H{ 18 | "userName": userName, 19 | "userId": userId, 20 | "currentPage": "login", 21 | "version": Version, 22 | "appname": cfg.AppName, 23 | "otp": s.ReqOTP, 24 | "warning": helpers.GetFlashCookie(c, "warning"), 25 | "error": helpers.GetFlashCookie(c, "error"), 26 | }) 27 | } 28 | 29 | func LoginHandler(c *gin.Context) { 30 | cfg := c.MustGet("Cfg").(config.WebConfig) 31 | Log.Debug(c.ClientIP(), " - POST /login") 32 | lang := cfg.Locale.Lang 33 | 34 | s := helpers.GetSession(c) 35 | s = helpers.FailLimiter(s, 30) // lock 30s after 4 failed logins 36 | 37 | username := c.PostForm("username") 38 | password := c.PostForm("password") 39 | code := c.PostForm("code") 40 | 41 | switch { 42 | case s.Lock: // == true 43 | s.User = username 44 | s.UserID = "" 45 | helpers.SetSession(c, s.ToJSONStr()) 46 | Log.Debug(c.ClientIP(), " - Lock Status for ", username) 47 | helpers.SetFlashCookie(c, "error", helpers.Tr(lang, "Too many errors, come back later")) 48 | c.Redirect(302, "/auth/login") 49 | case username != "" && password != "": 50 | valid := false 51 | u, err := GetUserByName(username) 52 | if err == nil { 53 | valid = u.ValidPass(password, cfg.PassPolicy.AllowReadSSHA256) 54 | } else { 55 | Log.Info(c.ClientIP(), " - No user ", username) 56 | } 57 | /*if *backend == "test" { 58 | valid = testValidateUser(username, password) 59 | } 60 | if *backend == "ldap" { 61 | valid = ldapValidateUser(username, password, config) 62 | }*/ 63 | if valid && !u.Disabled { 64 | tmpid := strconv.Itoa(u.UIDNumber) 65 | s.UserID = tmpid 66 | s.Count = 0 67 | 68 | groups := u.OtherGroups 69 | groups = append(groups, u.PrimaryGroup) 70 | useOtp := contains(groups, cfg.CfgUsers.GIDuseOtp) 71 | 72 | // redirect to otp if otp group and secret 73 | if u.OTPSecret != "" && useOtp { 74 | s.ReqOTP = true 75 | s.User = "" 76 | helpers.SetSession(c, s.ToJSONStr()) 77 | c.Redirect(302, "/auth/login") 78 | } else { // Auth success 79 | s.ReqOTP = false 80 | s.User = username 81 | helpers.SetSession(c, s.ToJSONStr()) 82 | c.Redirect(302, "/auth/user/"+tmpid) 83 | } 84 | } else { // Auth failed 85 | s.User = "" 86 | s.UserID = "" 87 | helpers.SetSession(c, s.ToJSONStr()) 88 | if u.Disabled { 89 | Log.Info(c.ClientIP(), " - AUTH failed for ", username, " : Account disabled") 90 | helpers.SetFlashCookie(c, "warning", helpers.Tr(lang, "Account disabled")) 91 | } else { 92 | Log.Info(c.ClientIP(), " - AUTH failed for ", username, "Bad credentials") 93 | helpers.SetFlashCookie(c, "warning", helpers.Tr(lang, "Bad credentials")) 94 | } 95 | c.Redirect(302, "/auth/login") 96 | } 97 | case s.UserID != "" && code != "": 98 | u := Data.Users[GetUserKey(s.UserID)] 99 | valid := u.ValidOTP(code, !cfg.Tests) 100 | if !valid { 101 | c.Redirect(302, "/auth/login") 102 | } else { // Auth success 103 | s.ReqOTP = false 104 | s.User = u.Name 105 | helpers.SetSession(c, s.ToJSONStr()) 106 | tmpid := strconv.Itoa(u.UIDNumber) 107 | c.Redirect(302, "/auth/user/"+tmpid) 108 | } 109 | default: 110 | Log.Error(c.ClientIP(), " - Bad Post params") 111 | c.HTML(404, "home/login.tmpl", nil) 112 | } 113 | } 114 | 115 | func LogoutHandler(c *gin.Context) { 116 | cfg := c.MustGet("Cfg").(config.WebConfig) 117 | lang := cfg.Locale.Lang 118 | 119 | helpers.ClearSession(c) 120 | helpers.SetFlashCookie(c, "success", helpers.Tr(lang, "You are disconnected")) 121 | c.Redirect(302, "/") 122 | } 123 | -------------------------------------------------------------------------------- /handlers/login_test.go: -------------------------------------------------------------------------------- 1 | //nolint 2 | package handlers 3 | 4 | import ( 5 | // "bytes" 6 | // "encoding/json". 7 | "fmt" 8 | 9 | "net/http" 10 | 11 | "strings" 12 | "testing" 13 | 14 | "github.com/gin-gonic/gin" 15 | "github.com/stretchr/testify/assert" 16 | 17 | . "glauth-ui-light/config" 18 | ) 19 | 20 | func TestLogin(t *testing.T) { 21 | // defer deleteFile(config.DBname) 22 | defer resetData() 23 | 24 | cfg := WebConfig{ 25 | DBfile: "sample-simple.cfg", 26 | Locale: Locale{ 27 | Lang: "fr", 28 | Path: "../locales/", 29 | }, 30 | Debug: true, 31 | Tests: true, 32 | CfgUsers: CfgUsers{ 33 | Start: 5000, 34 | GIDAdmin: 6501, 35 | GIDcanChgPass: 6500, 36 | GIDuseOtp: 6501, 37 | }, 38 | PassPolicy: PassPolicy{ 39 | AllowReadSSHA256: true, 40 | }, 41 | } 42 | 43 | initUsersValues() 44 | gin.SetMode(gin.TestMode) 45 | router := InitRouterTest(cfg) 46 | 47 | router.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "home/index.tmpl", nil) }) 48 | 49 | router.GET("/auth/login", LoginHandlerForm) 50 | router.POST("/auth/login", LoginHandler) 51 | router.GET("/auth/logout", LogoutHandler) 52 | router.Use(SetUserTest("user1", "5000", "admin")) 53 | router.GET("/auth/user/:id", UserProfile) 54 | // u.POST("/:id", UserChgPasswd) 55 | 56 | // Login 57 | fmt.Println("= Login") 58 | resp, _ := testLogin(t, router, "serviceapp", "dogood", nil) // user without otp 59 | assert.Equal(t, 200, resp.Code, "http GET success first user profile") 60 | fmt.Printf("%+v\n",resp) 61 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "> serviceapp"), "http GET success first user profile") 62 | 63 | Data.Users[2].Disabled = true // serviceapp user disabled 64 | resp, _ = testLogin(t, router, "serviceapp", "dogood", nil) // user without otp 65 | assert.Equal(t, 200, resp.Code, "http GET success first user profile") 66 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "alert-warning"), "account disabled") 67 | //fmt.Printf("%+v\n",resp) 68 | 69 | var cookie []*http.Cookie 70 | resp, cookie = testLogin(t, router, "user1", "dogood", nil) // user with otp 71 | assert.Equal(t, 200, resp.Code, "http GET success request OTP") 72 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "id=\"code\""), " waiting totp code") 73 | fmt.Printf("*** => Cookie: %+v\n", cookie) 74 | //fmt.Printf("%+v\n",resp) 75 | resp = testCode(t, router, "123456", cookie) // user with otp 76 | assert.Equal(t, 302, resp.Code, "not valid code") 77 | newurl, _ := resp.Result().Location() 78 | assert.Equal(t, "/auth/login", newurl.String(), "Bad login redirect to /auth/login") 79 | 80 | resp = testCode(t, router, "147756", cookie) // user with otp : hotp test 81 | assert.Equal(t, 302, resp.Code, "not valid code") 82 | newurl, _ = resp.Result().Location() 83 | assert.Equal(t, "/auth/user/5000", newurl.String(), "Bad login redirect to /auth/login") 84 | 85 | resp, _ = testLogin(t, router, "xxuser1", "dogood", nil) 86 | assert.Equal(t, 200, resp.Code, "http GET success first user profile") 87 | //newurl, _ := resp.Result().Location() 88 | //assert.Equal(t, "/auth/login", newurl.String(), "Bad login redirect to /auth/login") 89 | 90 | resp, _ = testLogin(t, router, "user1", "dogoodxxx", nil) 91 | assert.Equal(t, 200, resp.Code, "http GET success first user profile") 92 | //newurl, _ = resp.Result().Location() 93 | //assert.Equal(t, "/auth/login", newurl.String(), "Bad login redirect to /auth/login") 94 | 95 | resp, _ = testLogin(t, router, "", "dogood", nil) 96 | assert.Equal(t, 404, resp.Code, "Mandatory login and pass return error") 97 | resp, _ = testLogin(t, router, "user1", "", nil) 98 | assert.Equal(t, 404, resp.Code, "Mandatory login and pass return error") 99 | // fmt.Printf("%+v\n", resp) 100 | 101 | badpass := [4]string{"bad1", "bad2", "bad3", "bad4"} 102 | var c []*http.Cookie 103 | for i, b := range badpass { 104 | fmt.Println(i) 105 | resp, c = testLogin(t, router, "user1", b, c) 106 | assert.Equal(t, 200, resp.Code, "http GET success first user profile") 107 | if i >= 3 { 108 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "retentez plus tard"), "locked") 109 | //fmt.Printf("%+v\n", resp) 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /handlers/userProfile.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gin-gonic/gin" 7 | 8 | . "glauth-ui-light/config" 9 | . "glauth-ui-light/helpers" 10 | ) 11 | 12 | // Self user handlers 13 | 14 | func UserProfile(c *gin.Context) { 15 | cfg := c.MustGet("Cfg").(WebConfig) 16 | lang := cfg.Locale.Lang 17 | id := c.Params.ByName("id") 18 | 19 | if !isSelfAccess(c, "UserProfile", id) { 20 | return 21 | } 22 | 23 | k := ctlUserExist(c, lang, id) 24 | if k < 0 { 25 | return 26 | } 27 | 28 | u := Data.Users[k] 29 | userf := UserForm{ 30 | UIDNumber: u.UIDNumber, 31 | Mail: u.Mail, 32 | Name: u.Name, 33 | PrimaryGroup: u.PrimaryGroup, 34 | OtherGroups: u.OtherGroups, 35 | SN: u.SN, 36 | GivenName: u.GivenName, 37 | Disabled: u.Disabled, 38 | OTPSecret: u.OTPSecret, 39 | PassAppBcrypt: u.PassAppBcrypt, 40 | Lang: lang, 41 | } 42 | 43 | if userf.OTPSecret != "" { 44 | userf.CreateOTPimg(cfg.AppName) 45 | } 46 | 47 | render(c, gin.H{"title": u.Name, "u": userf, "currentPage": "profile", "groupdata": Data.Groups}, "user/profile.tmpl") 48 | } 49 | 50 | func UserChgPasswd(c *gin.Context) { 51 | cfg := c.MustGet("Cfg").(WebConfig) 52 | lang := cfg.Locale.Lang 53 | id := c.Params.ByName("id") 54 | 55 | // Ctrl access 56 | if !isSelfAccess(c, "UserChgPasswd", id) { 57 | return 58 | } 59 | 60 | k := ctlUserExist(c, lang, id) 61 | if k < 0 { 62 | return 63 | } 64 | 65 | // Ctrl access with message 66 | u := Data.Users[k] 67 | role := c.MustGet("Role").(string) 68 | 69 | userf := &UserForm{ 70 | UIDNumber: u.UIDNumber, 71 | Mail: u.Mail, 72 | Name: u.Name, 73 | PrimaryGroup: u.PrimaryGroup, 74 | OtherGroups: u.OtherGroups, 75 | SN: u.SN, 76 | GivenName: u.GivenName, 77 | Disabled: u.Disabled, 78 | OTPSecret: u.OTPSecret, 79 | PassAppBcrypt: u.PassAppBcrypt, 80 | Lang: lang, 81 | } 82 | userf.Errors = make(map[string]string) 83 | 84 | if userf.OTPSecret != "" { 85 | userf.CreateOTPimg(cfg.AppName) 86 | } 87 | 88 | // application accounts don't change their password 89 | // users and admins are defined by group set by GIDcanChgPass, GIDAdmin config 90 | if (role != "admin" && role != "user") || Lock != 0 { 91 | warning := "" 92 | if Lock != 0 { 93 | warning = Tr(lang, "Data locked by admin.") 94 | } 95 | render(c, gin.H{ 96 | "title": u.Name, 97 | "currentPage": "profile", 98 | "warning": warning, 99 | "u": userf, 100 | "groupdata": Data.Groups}, 101 | "user/profile.tmpl") 102 | return 103 | } 104 | 105 | pass1 := c.PostForm("inputPassword") 106 | pass2 := c.PostForm("inputPassword2") 107 | 108 | // Validate entries 109 | if pass1 == "" { 110 | userf.Errors["Password"] = Tr(lang, "Mandatory") 111 | } 112 | if pass1 != pass2 { 113 | userf.Errors["Password2"] = Tr(lang, "Passwords mismatch") 114 | } 115 | if pass2 == "" { 116 | userf.Errors["Password2"] = Tr(lang, "Mandatory") 117 | } 118 | if len(userf.Errors) != 0 { 119 | render(c, gin.H{"title": u.Name, "currentPage": "profile", "u": userf, "groupdata": Data.Groups}, "user/profile.tmpl") 120 | return 121 | } 122 | userf.Password = pass1 123 | 124 | // Validate new password 125 | if !userf.Validate(cfg.PassPolicy) { 126 | render(c, gin.H{"title": u.Name, "currentPage": "profile", "u": userf, "groupdata": Data.Groups}, "user/profile.tmpl") 127 | return 128 | } 129 | 130 | (&Data.Users[k]).SetBcryptPass(pass1) 131 | (&Data.Users[k]).PassSHA256 = "" // no more use of SHA256 132 | 133 | username := c.MustGet("Login").(string) 134 | Log.Info(fmt.Sprintf("%s -- %s password changed by %s", c.ClientIP(), u.Name, username)) 135 | 136 | err := WriteDB(&cfg, Data, username) 137 | if err != nil { 138 | render(c, gin.H{"title": Tr(lang, "Error"), "currentPage": "profile", "error": err.Error()}, "home/error.tmpl") 139 | return 140 | } 141 | 142 | render(c, gin.H{ 143 | "title": u.Name, 144 | "currentPage": "profile", 145 | "success": Tr(lang, "Password updated"), 146 | "u": userf, 147 | "groupdata": Data.Groups}, 148 | "user/profile.tmpl") 149 | } 150 | 151 | func UserChgOTP(c *gin.Context) { 152 | cfg := c.MustGet("Cfg").(WebConfig) 153 | lang := cfg.Locale.Lang 154 | id := c.Params.ByName("id") 155 | 156 | // Ctrl access 157 | if !isSelfAccess(c, "UserChgOTP", id) { 158 | return 159 | } 160 | 161 | k := ctlUserExist(c, lang, id) 162 | if k < 0 { 163 | return 164 | } 165 | 166 | // Ctrl access with message 167 | u := Data.Users[k] 168 | 169 | userf := &UserForm{ 170 | UIDNumber: u.UIDNumber, 171 | Mail: u.Mail, 172 | Name: u.Name, 173 | PrimaryGroup: u.PrimaryGroup, 174 | OtherGroups: u.OtherGroups, 175 | SN: u.SN, 176 | GivenName: u.GivenName, 177 | Disabled: u.Disabled, 178 | OTPSecret: u.OTPSecret, 179 | PassAppBcrypt: u.PassAppBcrypt, 180 | Lang: lang, 181 | } 182 | userf.Errors = make(map[string]string) 183 | 184 | if userf.OTPSecret != "" { 185 | userf.CreateOTPimg(cfg.AppName) 186 | } 187 | 188 | groups := u.OtherGroups 189 | groups = append(groups, u.PrimaryGroup) 190 | useOtp := contains(groups, cfg.CfgUsers.GIDuseOtp) 191 | 192 | if !useOtp || Lock != 0 { // only for members of GIDuseOtp 193 | warning := "" 194 | if Lock != 0 { 195 | warning = Tr(lang, "Data locked by admin.") 196 | } 197 | render(c, gin.H{ 198 | "title": u.Name, 199 | "currentPage": "profile", 200 | "warning": warning, 201 | "navotp": true, 202 | "u": userf, 203 | "groupdata": Data.Groups}, 204 | "user/profile.tmpl") 205 | return 206 | } 207 | 208 | otp := c.PostForm("inputOTPSecret") 209 | userf.OTPSecret = otp 210 | 211 | // Validate new otpsecret or no change 212 | if !userf.Validate(cfg.PassPolicy) || otp == (&Data.Users[k]).OTPSecret { 213 | userf.OTPSecret = (&Data.Users[k]).OTPSecret 214 | render(c, gin.H{"title": u.Name, 215 | "currentPage": "profile", 216 | "navotp": true, 217 | "u": userf, 218 | "groupdata": Data.Groups}, "user/profile.tmpl") 219 | return 220 | } 221 | 222 | (&Data.Users[k]).OTPSecret = userf.OTPSecret 223 | 224 | username := c.MustGet("Login").(string) 225 | Log.Info(fmt.Sprintf("%s -- %s otp secret changed by %s", c.ClientIP(), u.Name, username)) 226 | 227 | err := WriteDB(&cfg, Data, username) 228 | if err != nil { 229 | render(c, gin.H{"title": Tr(lang, "Error"), "currentPage": "profile", "error": err.Error()}, "home/error.tmpl") 230 | return 231 | } 232 | 233 | if userf.OTPSecret != "" { 234 | userf.CreateOTPimg(cfg.AppName) 235 | } 236 | 237 | render(c, gin.H{ 238 | "title": u.Name, 239 | "currentPage": "profile", 240 | "success": Tr(lang, "OTP updated"), 241 | "navotp": true, 242 | "u": userf, 243 | "groupdata": Data.Groups}, 244 | "user/profile.tmpl") 245 | } 246 | 247 | func UserPassApp(c *gin.Context) { 248 | cfg := c.MustGet("Cfg").(WebConfig) 249 | lang := cfg.Locale.Lang 250 | id := c.Params.ByName("id") 251 | 252 | // Ctrl access 253 | if !isSelfAccess(c, "UserPassApp", id) { 254 | return 255 | } 256 | 257 | k := ctlUserExist(c, lang, id) 258 | if k < 0 { 259 | return 260 | } 261 | 262 | // Ctrl access with message 263 | u := Data.Users[k] 264 | 265 | userf := &UserForm{ 266 | UIDNumber: u.UIDNumber, 267 | Mail: u.Mail, 268 | Name: u.Name, 269 | PrimaryGroup: u.PrimaryGroup, 270 | OtherGroups: u.OtherGroups, 271 | SN: u.SN, 272 | GivenName: u.GivenName, 273 | Disabled: u.Disabled, 274 | OTPSecret: u.OTPSecret, 275 | PassAppBcrypt: u.PassAppBcrypt, 276 | Lang: lang, 277 | } 278 | userf.Errors = make(map[string]string) 279 | 280 | if userf.OTPSecret != "" { 281 | userf.CreateOTPimg(cfg.AppName) 282 | } 283 | 284 | groups := u.OtherGroups 285 | groups = append(groups, u.PrimaryGroup) 286 | useOtp := contains(groups, cfg.CfgUsers.GIDuseOtp) 287 | 288 | if !useOtp || Lock != 0 { // only for members of GIDuseOtp 289 | warning := "" 290 | if Lock != 0 { 291 | warning = Tr(lang, "Data locked by admin.") 292 | } 293 | render(c, gin.H{ 294 | "title": u.Name, 295 | "currentPage": "profile", 296 | "warning": warning, 297 | "navotp": true, 298 | "u": userf, 299 | "groupdata": Data.Groups}, 300 | "user/profile.tmpl") 301 | return 302 | } 303 | 304 | // Read input 305 | username := c.MustGet("Login").(string) 306 | 307 | userf.NewPassApp = c.PostForm("inputNewPassApp") 308 | 309 | change := false 310 | // Remove pass app 311 | for d := 0; d < 3; d++ { 312 | input := fmt.Sprintf("inputDelPassApp%d", d) 313 | delpass := c.PostForm(input) 314 | if delpass != "" { 315 | (&Data.Users[k]).DelPassApp(d) 316 | change = true 317 | Log.Info(fmt.Sprintf("%s -- %s passapp removed %d by %s", c.ClientIP(), u.Name, d, username)) 318 | } 319 | } 320 | 321 | // Validate and register newpass 322 | if userf.NewPassApp != "" { 323 | if !userf.Validate(cfg.PassPolicy) { 324 | render(c, gin.H{"title": u.Name, 325 | "currentPage": "profile", 326 | "navotp": true, 327 | "u": userf, 328 | "groupdata": Data.Groups}, "user/profile.tmpl") 329 | return 330 | } 331 | 332 | (&Data.Users[k]).AddPassApp(userf.NewPassApp) 333 | change = true 334 | Log.Info(fmt.Sprintf("%s -- %s passapp added by %s", c.ClientIP(), u.Name, username)) 335 | } 336 | 337 | if change { 338 | userf.PassAppBcrypt = Data.Users[k].PassAppBcrypt 339 | } 340 | 341 | err := WriteDB(&cfg, Data, username) 342 | if err != nil { 343 | render(c, gin.H{"title": Tr(lang, "Error"), "currentPage": "profile", "error": err.Error()}, "home/error.tmpl") 344 | return 345 | } 346 | 347 | render(c, gin.H{ 348 | "title": u.Name, 349 | "currentPage": "profile", 350 | "success": Tr(lang, "Tokens changed"), 351 | "navotp": true, 352 | "u": userf, 353 | "groupdata": Data.Groups}, 354 | "user/profile.tmpl") 355 | } 356 | -------------------------------------------------------------------------------- /handlers/users.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/gin-gonic/gin" 11 | 12 | "encoding/base32" 13 | "encoding/base64" 14 | "image/png" 15 | 16 | "github.com/pquerna/otp" 17 | 18 | passwordvalidator "github.com/wagslane/go-password-validator" 19 | 20 | . "glauth-ui-light/config" 21 | . "glauth-ui-light/helpers" 22 | ) 23 | 24 | // Validate entries 25 | 26 | var rxEmail = regexp.MustCompile(".+@.+\\..+") //nolint 27 | var rxName = regexp.MustCompile("^[a-z0-9]+$") 28 | 29 | var rxBadChar = regexp.MustCompile("[<>&*%$'«».,;:!` ]+") 30 | 31 | type UserForm struct { 32 | UIDNumber int 33 | Name string 34 | Mail string 35 | Homedir string 36 | LoginShell string 37 | SN string 38 | GivenName string 39 | Password string 40 | OTPSecret string 41 | OTPImg string 42 | PassAppBcrypt []string 43 | SSHKeys []string 44 | NewPassApp string 45 | PrimaryGroup int 46 | OtherGroups []int 47 | Disabled bool 48 | Errors map[string]string 49 | Lang string 50 | } 51 | 52 | func (userf *UserForm) CreateOTPimg(appname string) { 53 | url := fmt.Sprintf("otpauth://totp/%s%%3A%s?secret=%s&issuer=%s", appname, userf.Name, userf.OTPSecret, appname) 54 | if appname == "" { 55 | fmt.Println("totp.Generate: Mandatory AppName") 56 | return 57 | } 58 | key, _ := otp.NewKeyFromURL(url) 59 | var buf bytes.Buffer 60 | img, _ := key.Image(200, 200) 61 | e := png.Encode(&buf, img) 62 | if e != nil { 63 | fmt.Println("png.Encode: " + e.Error()) 64 | return 65 | } 66 | userf.OTPImg = base64.StdEncoding.EncodeToString(buf.Bytes()) 67 | } 68 | 69 | func (userf *UserForm) Validate(cfg PassPolicy) bool { 70 | lang := userf.Lang 71 | userf.Errors = make(map[string]string) 72 | 73 | match := rxEmail.MatchString(userf.Mail) 74 | if userf.Mail != "" && !match { 75 | userf.Errors["Mail"] = Tr(lang, "Please enter a valid email address") 76 | } 77 | 78 | p := userf.Password 79 | if p != "" { 80 | switch { 81 | case len(p) < cfg.Min: 82 | userf.Errors["Password"] = Tr(lang, "Too short") 83 | case len(p) > cfg.Max: 84 | userf.Errors["Password"] = Tr(lang, "Too long") 85 | case cfg.Entropy != 0: 86 | err := passwordvalidator.Validate(p, float64(cfg.Entropy)) 87 | if err != nil { 88 | userf.Errors["Password"] = Tr(lang, "Insecure password") 89 | } 90 | } 91 | } 92 | 93 | np := userf.NewPassApp 94 | if np != "" { 95 | switch { 96 | case len(np) < cfg.Min: 97 | userf.Errors["NewPassApp"] = Tr(lang, "Too short") 98 | case len(np) > cfg.Max: 99 | userf.Errors["NewPassApp"] = Tr(lang, "Too long") 100 | } 101 | } 102 | 103 | o := userf.OTPSecret 104 | if o != "" { 105 | _, err := base32.StdEncoding.DecodeString(strings.ToUpper(o)) 106 | switch { 107 | case len(o) < 16: 108 | userf.Errors["OTPSecret"] = Tr(lang, "Too short") 109 | case len(o) > 33: 110 | userf.Errors["OTPSecret"] = Tr(lang, "Too long") 111 | case err != nil: 112 | userf.Errors["OTPSecret"] = Tr(lang, "Wrong base32") 113 | } 114 | } 115 | 116 | n := userf.Name 117 | matchName := rxName.MatchString(n) 118 | switch { 119 | case strings.TrimSpace(n) == "": 120 | userf.Errors["Name"] = Tr(lang, "Mandatory") 121 | case len(n) < 2: 122 | userf.Errors["Name"] = Tr(lang, "Too short") 123 | case len(n) > 16: 124 | userf.Errors["Name"] = Tr(lang, "Too long") 125 | case !matchName: 126 | userf.Errors["Name"] = Tr(lang, "Bad character") 127 | } 128 | for k := range Data.Users { 129 | if Data.Users[k].Name == n && Data.Users[k].UIDNumber != userf.UIDNumber { 130 | userf.Errors["Name"] = Tr(lang, "Name already used") 131 | break 132 | } 133 | } 134 | 135 | matchBadSN := rxBadChar.MatchString(userf.SN) 136 | if userf.SN != "" && len(userf.SN) > 32 { 137 | userf.Errors["SN"] = Tr(lang, "Too long") 138 | } 139 | if userf.SN != "" && matchBadSN { 140 | userf.Errors["SN"] = Tr(lang, "Bad character") 141 | } 142 | 143 | matchBadGname := rxBadChar.MatchString(userf.GivenName) 144 | if userf.GivenName != "" && len(userf.GivenName) > 32 { 145 | userf.Errors["GivenName"] = Tr(lang, "Too long") 146 | } 147 | if userf.GivenName != "" && matchBadGname { 148 | userf.Errors["GivenName"] = Tr(lang, "Bad character") 149 | } 150 | 151 | if userf.UIDNumber < 0 { 152 | userf.Errors["UIDNumber"] = Tr(lang, "Unknown user") 153 | } 154 | 155 | matchBadHomedir := rxBadChar.MatchString(userf.Homedir) 156 | if userf.Homedir != "" && len(userf.Homedir) > 128 { 157 | userf.Errors["Homedir"] = Tr(lang, "Too long") 158 | } 159 | if userf.Homedir != "" && matchBadHomedir { 160 | userf.Errors["Homedir"] = Tr(lang, "Bad character") 161 | } 162 | 163 | validLoginShell := []string{"/bin/bash", "/bin/sh", "/bin/false"} 164 | if userf.LoginShell != "" && strContains(validLoginShell, userf.LoginShell) == false { 165 | userf.Errors["LoginShell"] = Tr(lang, "Forbidden LoginShell") 166 | } 167 | 168 | return len(userf.Errors) == 0 169 | } 170 | 171 | func strContains(s []string, e string) bool { 172 | for _, a := range s { 173 | if a == e { 174 | return true 175 | } 176 | } 177 | return false 178 | } 179 | 180 | // Helpers 181 | 182 | func ctlUserExist(c *gin.Context, lang string, id string) int { 183 | k := GetUserKey(id) 184 | if k < 0 { 185 | render(c, gin.H{"title": Tr(lang, "Error"), "currentPage": "user", "error": Tr(lang, "Unknown user")}, "home/error.tmpl") 186 | return -1 187 | } 188 | return k 189 | } 190 | 191 | func GetUserKey(id string) int { 192 | i := -1 193 | intId, _ := strconv.Atoi(id) 194 | for k := range Data.Users { 195 | if Data.Users[k].UIDNumber == intId { 196 | i = k 197 | break 198 | } 199 | } 200 | return i 201 | } 202 | 203 | func GetUserByName(name string) (User, error) { 204 | for k := range Data.Users { 205 | if Data.Users[k].Name == name { 206 | return Data.Users[k], nil 207 | } 208 | } 209 | return User{}, fmt.Errorf("unknown user") 210 | } 211 | 212 | // Handlers 213 | 214 | func UserList(c *gin.Context) { 215 | cfg := c.MustGet("Cfg").(WebConfig) 216 | lang := cfg.Locale.Lang 217 | 218 | if !isAdminAccess(c, "UserList", "-") { 219 | return 220 | } 221 | 222 | hg := make(map[int]string) 223 | for k := range Data.Groups { 224 | hg[Data.Groups[k].GIDNumber] = Data.Groups[k].Name 225 | } 226 | render(c, gin.H{"title": Tr(lang, "Users page"), "currentPage": "user", "userdata": Data.Users, "hashgroups": hg}, "user/list.tmpl") 227 | } 228 | 229 | func UserEdit(c *gin.Context) { 230 | cfg := c.MustGet("Cfg").(WebConfig) 231 | lang := cfg.Locale.Lang 232 | id := c.Params.ByName("id") 233 | 234 | if !isAdminAccess(c, "UserEdit", id) { 235 | return 236 | } 237 | 238 | k := ctlUserExist(c, lang, id) 239 | if k < 0 { 240 | return 241 | } 242 | 243 | u := Data.Users[k] 244 | userf := UserForm{ 245 | UIDNumber: u.UIDNumber, 246 | Mail: u.Mail, 247 | Name: u.Name, 248 | Homedir: u.Homedir, 249 | LoginShell: u.LoginShell, 250 | PrimaryGroup: u.PrimaryGroup, 251 | OtherGroups: u.OtherGroups, 252 | SN: u.SN, 253 | GivenName: u.GivenName, 254 | Disabled: u.Disabled, 255 | OTPSecret: u.OTPSecret, 256 | PassAppBcrypt: u.PassAppBcrypt, 257 | SSHKeys: u.SSHKeys, 258 | Lang: lang, 259 | } 260 | 261 | if userf.OTPSecret != "" { 262 | userf.CreateOTPimg(cfg.AppName) 263 | } 264 | 265 | render(c, gin.H{"title": Tr(lang, "Edit user"), "currentPage": "user", "u": userf, "groupdata": Data.Groups}, "user/edit.tmpl") 266 | } 267 | 268 | func UserUpdate(c *gin.Context) { 269 | cfg := c.MustGet("Cfg").(WebConfig) 270 | lang := cfg.Locale.Lang 271 | id := c.Params.ByName("id") 272 | 273 | if !isAdminAccess(c, "UserUpdate", id) { 274 | return 275 | } 276 | 277 | k := ctlUserExist(c, lang, id) 278 | if k < 0 { 279 | return 280 | } 281 | 282 | // Convert string to right format 283 | var err error 284 | var pg int 285 | 286 | if c.PostForm("inputGroup") != "" { 287 | pg, err = strconv.Atoi(c.PostForm("inputGroup")) 288 | } 289 | ogStr := c.PostFormArray("inputOtherGroup") 290 | d := false 291 | if c.PostForm("inputDisabled") == "on" { 292 | d = true 293 | } 294 | og := []int{} 295 | for k := range ogStr { 296 | i, e := strconv.Atoi(ogStr[k]) 297 | if e != nil { 298 | err = e 299 | } 300 | og = append(og, i) 301 | } 302 | if err != nil { 303 | render(c, gin.H{"title": Tr(lang, "Error"), "currentPage": "user", "error": err.Error()}, "home/error.tmpl") 304 | return 305 | } 306 | 307 | // Bind form to struct 308 | userf := &UserForm{ 309 | UIDNumber: Data.Users[k].UIDNumber, 310 | Mail: c.PostForm("inputMail"), 311 | Name: c.PostForm("inputName"), 312 | Homedir: c.PostForm("inputHomedir"), 313 | LoginShell: c.PostForm("inputLoginShell"), 314 | SN: c.PostForm("inputSN"), 315 | GivenName: c.PostForm("inputGivenName"), 316 | Password: c.PostForm("inputPassword"), 317 | OTPSecret: c.PostForm("inputOTPSecret"), 318 | NewPassApp: c.PostForm("inputNewPassApp"), 319 | PassAppBcrypt: Data.Users[k].PassAppBcrypt, 320 | PrimaryGroup: pg, 321 | OtherGroups: og, 322 | Disabled: d, 323 | Lang: lang, 324 | } 325 | // fmt.Printf("%+v\n", userf) 326 | if userf.OTPSecret != "" { 327 | userf.CreateOTPimg(cfg.AppName) 328 | } 329 | 330 | // Validate entries 331 | if !userf.Validate(cfg.PassPolicy) { 332 | render(c, gin.H{"title": Tr(lang, "Edit user"), "currentPage": "user", "u": userf, "groupdata": Data.Groups}, "user/edit.tmpl") 333 | return 334 | } 335 | 336 | // Update Data 337 | // updateUser := &Data.Users[k] 338 | (&Data.Users[k]).Name = userf.Name 339 | (&Data.Users[k]).Homedir = userf.Homedir 340 | (&Data.Users[k]).LoginShell = userf.LoginShell 341 | (&Data.Users[k]).PrimaryGroup = userf.PrimaryGroup 342 | (&Data.Users[k]).OtherGroups = og 343 | (&Data.Users[k]).SN = userf.SN 344 | (&Data.Users[k]).GivenName = userf.GivenName 345 | (&Data.Users[k]).Mail = userf.Mail 346 | (&Data.Users[k]).Disabled = d 347 | (&Data.Users[k]).OTPSecret = userf.OTPSecret 348 | if userf.Password != "" { // optional set password 349 | (&Data.Users[k]).PassSHA256 = "" // no more use of SHA256 350 | (&Data.Users[k]).SetBcryptPass(userf.Password) 351 | } 352 | 353 | for d := 0; d < 3; d++ { 354 | input := fmt.Sprintf("inputDelPassApp%d", d) 355 | delpass := c.PostForm(input) 356 | if delpass != "" { 357 | (&Data.Users[k]).DelPassApp(d) 358 | } 359 | } 360 | if userf.NewPassApp != "" { 361 | (&Data.Users[k]).AddPassApp(userf.NewPassApp) 362 | } 363 | userf.PassAppBcrypt = Data.Users[k].PassAppBcrypt 364 | 365 | Lock++ 366 | 367 | Log.Info(fmt.Sprintf("%s -- %s updated by %s", c.ClientIP(), userf.Name, c.MustGet("Login").(string))) 368 | 369 | render(c, gin.H{ 370 | "title": Tr(lang, "Edit user"), 371 | "currentPage": "user", 372 | "success": "«" + userf.Name + "» updated", 373 | "u": userf, 374 | "groupdata": Data.Groups}, 375 | "user/edit.tmpl") 376 | } 377 | 378 | func UserAdd(c *gin.Context) { 379 | cfg := c.MustGet("Cfg").(WebConfig) 380 | lang := cfg.Locale.Lang 381 | 382 | if !isAdminAccess(c, "UserAdd", "-") { 383 | return 384 | } 385 | 386 | render(c, gin.H{"title": Tr(lang, "Add user"), "currentPage": "user"}, "user/create.tmpl") 387 | } 388 | 389 | func UserCreate(c *gin.Context) { 390 | cfg := c.MustGet("Cfg").(WebConfig) 391 | lang := cfg.Locale.Lang 392 | 393 | if !isAdminAccess(c, "UserCreate", "-") { 394 | return 395 | } 396 | 397 | // Bind form to struct 398 | userf := &UserForm{ 399 | Name: c.PostForm("inputName"), 400 | Lang: lang, 401 | } 402 | // Validate entries 403 | if !userf.Validate(cfg.PassPolicy) { 404 | render(c, gin.H{"title": Tr(lang, "Add user"), "currentPage": "user", "u": userf, "groupdata": Data.Groups}, "user/create.tmpl") 405 | return 406 | } 407 | 408 | // Create new id 409 | nextID := cfg.CfgUsers.Start - 1 // start uidnumber via config 410 | for k := range Data.Users { 411 | if Data.Users[k].UIDNumber >= nextID { 412 | nextID = Data.Users[k].UIDNumber 413 | } 414 | } 415 | userf.UIDNumber = nextID + 1 416 | // Add User to Data 417 | newUser := User{UIDNumber: userf.UIDNumber, Name: userf.Name} 418 | Data.Users = append(Data.Users, newUser) 419 | 420 | Lock++ 421 | 422 | Log.Info(fmt.Sprintf("%s -- %s created by %s", c.ClientIP(), newUser.Name, c.MustGet("Login").(string))) 423 | 424 | SetFlashCookie(c, "success", "«"+newUser.Name+"» added") 425 | c.Redirect(302, fmt.Sprintf("/auth/crud/user/%d", newUser.UIDNumber)) 426 | } 427 | 428 | func UserDel(c *gin.Context) { 429 | cfg := c.MustGet("Cfg").(WebConfig) 430 | lang := cfg.Locale.Lang 431 | id := c.Params.ByName("id") 432 | 433 | if !isAdminAccess(c, "UserDel", id) { 434 | return 435 | } 436 | 437 | k := ctlUserExist(c, lang, id) 438 | if k < 0 { 439 | return 440 | } 441 | 442 | deletedUser := Data.Users[k] 443 | 444 | Data.Users = append(Data.Users[:k], Data.Users[k+1:]...) 445 | 446 | Lock++ 447 | 448 | Log.Info(fmt.Sprintf("%s -- %s deleted by %s", c.ClientIP(), deletedUser.Name, c.MustGet("Login").(string))) 449 | 450 | SetFlashCookie(c, "success", "«"+deletedUser.Name+"» deleted") 451 | c.Redirect(302, "/auth/crud/user") 452 | } 453 | -------------------------------------------------------------------------------- /helpers/_sample-simple.cfg.orig: -------------------------------------------------------------------------------- 1 | ################# 2 | # glauth.conf 3 | 4 | ################# 5 | # General configuration. 6 | debug = true 7 | # syslog = true 8 | # 9 | # Enable hot-reload of configuration on changes 10 | # - does NOT work [ldap], [ldaps], [backend] or [api] sections 11 | # watchconfig = true 12 | 13 | ################# 14 | # yubikeyclientid = "yubi-api-clientid" 15 | # yubikeysecret = "yubi-api-secret" 16 | 17 | ################# 18 | # Server configuration. 19 | [ldap] 20 | enabled = true 21 | # run on a non privileged port 22 | listen = "0.0.0.0:3893" 23 | 24 | [ldaps] 25 | # to enable ldaps genrerate a certificate, eg. with: 26 | # openssl req -x509 -newkey rsa:4096 -keyout glauth.key -out glauth.crt -days 365 -nodes -subj '/CN=`hostname`' 27 | enabled = false 28 | listen = "0.0.0.0:3894" 29 | cert = "glauth.crt" 30 | key = "glauth.key" 31 | 32 | ################# 33 | # The backend section controls the data store. 34 | [backend] 35 | datastore = "config" 36 | baseDN = "dc=glauth,dc=com" 37 | nameformat = "cn" 38 | groupformat = "ou" 39 | 40 | ## Configure dn format to use structures like 41 | ## "uid=serviceuser,cn=svcaccts,$BASEDN" instead of "cn=serviceuser,ou=svcaccts,$BASEDN" 42 | ## to help ease migrations from other LDAP systems 43 | # nameformat = "uid" 44 | # groupformat = "cn" 45 | 46 | ## Configure ssh-key attribute name, default is 'sshPublicKey' 47 | # sshkeyattr = "ipaSshPubKey" 48 | 49 | [behaviors] 50 | # Ignore all capabilities restrictions, for instance allowing every user to perform a search 51 | IgnoreCapabilities = false 52 | # Enable a "fail2ban" type backoff mechanism temporarily banning repeated failed login attempts 53 | LimitFailedBinds = true 54 | # How many failed login attempts are allowed before a ban is imposed 55 | NumberOfFailedBinds = 3 56 | # How long (in seconds) is the window for failed login attempts 57 | PeriodOfFailedBinds = 10 58 | # How long (in seconds) is the ban duration 59 | BlockFailedBindsFor = 60 60 | # Clean learnt IP addresses every N seconds 61 | PruneSourceTableEvery = 600 62 | # Clean learnt IP addresses not seen in N seconds 63 | PruneSourcesOlderThan = 600 64 | 65 | ################# 66 | # Enable and configure the optional REST API here. 67 | [api] 68 | enabled = true 69 | internals = true # debug application performance 70 | tls = false # enable TLS for production!! 71 | listen = "0.0.0.0:5555" 72 | cert = "cert.pem" 73 | key = "key.pem" 74 | 75 | ################# 76 | # The users section contains a hardcoded list of valid users. 77 | # to create a passSHA256: echo -n "mysecret" | openssl dgst -sha256 78 | [[users]] 79 | name = "hackers" 80 | uidnumber = 5001 81 | primarygroup = 5501 82 | passsha256 = "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a" # dogood 83 | [[users.customattributes]] 84 | employeetype = ["Intern", "Temp"] 85 | employeenumber = [12345, 54321] 86 | [[users.capabilities]] 87 | action = "search" 88 | object = "ou=superheros,dc=glauth,dc=com" 89 | 90 | # This user record shows all of the possible fields available 91 | [[users]] 92 | name = "johndoe" 93 | givenname="John" 94 | sn="Doe" 95 | mail = "jdoe@example.com" 96 | uidnumber = 5002 97 | primarygroup = 5501 98 | loginShell = "/bin/sh" 99 | homeDir = "/root" 100 | passsha256 = "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a" # dogood 101 | sshkeys = ["ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAQEA3UKCEllO2IZXgqNygiVb+dDLJJwVw3AJwV34t2jzR+/tUNVeJ9XddKpYQektNHsFmY93lJw5QDSbeH/mAC4KPoUM47EriINKEelRbyG4hC/ko/e2JWqEclPS9LP7GtqGmscXXo4JFkqnKw4TIRD52XI9n1syYM9Y8rJ88fjC/Lpn+01AB0paLVIfppJU35t0Ho9doHAEfEvcQA6tcm7FLJUvklAxc8WUbdziczbRV40KzDroIkXAZRjX7vXXhh/p7XBYnA0GO8oTa2VY4dTQSeDAUJSUxbzevbL0ll9Gi1uYaTDQyE5gbn2NfJSqq0OYA+3eyGtIVjFYZgi+txSuhw== rsa-key-20160209"] 102 | passappsha256 = [ 103 | "c32255dbf6fd6b64883ec8801f793bccfa2a860f2b1ae1315cd95cdac1338efa", # TestAppPw1 104 | "c9853d5f2599e90497e9f8cc671bd2022b0fb5d1bd7cfff92f079e8f8f02b8d3", # TestAppPw2 105 | "4939efa7c87095dacb5e7e8b8cfb3a660fa1f5edcc9108f6d7ec20ea4d6b3a88", # TestAppPw3 106 | ] 107 | 108 | [[users]] 109 | name = "serviceuser" 110 | mail = "serviceuser@example.com" 111 | uidnumber = 5003 112 | primarygroup = 5502 113 | passsha256 = "652c7dc687d98c9889304ed2e408c74b611e86a40caa51c4b43f1dd5913c5cd0" # mysecret 114 | [[users.capabilities]] 115 | action = "search" 116 | object = "*" 117 | 118 | # Test user showing 2 factor auth authentication 119 | [[users]] 120 | name = "otpuser" 121 | uidnumber = 5004 122 | primarygroup = 5501 123 | passsha256 = "652c7dc687d98c9889304ed2e408c74b611e86a40caa51c4b43f1dd5913c5cd0" # mysecret 124 | otpsecret = "3hnvnk4ycv44glzigd6s25j4dougs3rk" 125 | yubikey = "vvjrcfalhlaa" 126 | [[users.capabilities]] 127 | action = "search" 128 | object = "ou=superheros,dc=glauth,dc=com" 129 | 130 | [[users]] 131 | name = "uberhackers" 132 | uidnumber = 5005 133 | primarygroup = 5501 134 | # bcrypt format: hex($2y$2^$$) 135 | passappbcrypt = [ 136 | "243261243130244B62463462656F7265504F762E794F324957746D656541326B4B46596275674A79336A476845764B616D65446169784E41384F4432" # dogood 137 | ] 138 | # uncomment and comment out above array to test password with otp code 139 | # passappbcrypt = "243261243130244B62463462656F7265504F762E794F324957746D656541326B4B46596275674A79336A476845764B616D65446169784E41384F4432" # dogood 140 | otpsecret = "3hnvnk4ycv44glzigd6s25j4dougs3rk" 141 | othergroups = [5502,5503] 142 | 143 | 144 | ################# 145 | # The groups section contains a hardcoded list of valid users. 146 | [[groups]] 147 | name = "superheros" 148 | gidnumber = 5501 149 | 150 | [[groups]] 151 | name = "svcaccts" 152 | gidnumber = 5502 153 | 154 | [[groups]] 155 | name = "vpn" 156 | gidnumber = 5503 157 | includegroups = [ 5501 ] 158 | 159 | -------------------------------------------------------------------------------- /helpers/auth_test.go: -------------------------------------------------------------------------------- 1 | //nolint 2 | package helpers 3 | 4 | import ( 5 | //"bytes" 6 | //"encoding/json" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "net/url" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/gin-gonic/gin" 15 | "github.com/stretchr/testify/assert" 16 | 17 | . "glauth-ui-light/config" 18 | ) 19 | 20 | func TestI18n(t *testing.T) { 21 | cfg := WebConfig{ 22 | Locale: Locale{ 23 | Lang: "fr", 24 | Path: "../locales", 25 | }, 26 | } 27 | InitRouterTest(cfg) 28 | assert.Equal(t, "Mail", Tr("fr", "Mail"), "i18n") 29 | assert.Equal(t, "Identifiant", Tr("fr", "Login"), "i18n") 30 | } 31 | 32 | func TestHelpersSession(t *testing.T) { 33 | 34 | cfg := WebConfig{ 35 | Locale: Locale{ 36 | Lang: "fr", 37 | Path: "../locales", 38 | }, 39 | Debug: true, 40 | Sec: Sec{ 41 | CSRFrandom: "secret", 42 | }, 43 | CfgUsers: CfgUsers{ 44 | Start: 5000, 45 | GIDAdmin: 6501, 46 | GIDcanChgPass: 6500, 47 | }, 48 | PassPolicy: PassPolicy{ 49 | AllowReadSSHA256: true, 50 | }, 51 | } 52 | 53 | initUsersValues() 54 | //fmt.Printf("%+v\n",Data) 55 | gin.SetMode(gin.TestMode) 56 | router := InitRouterTest(cfg) 57 | 58 | router.GET("/auth/login", LoginTestHandlerForm) 59 | router.POST("/auth/login", LoginTestHandler) 60 | router.GET("/auth/logout", LogoutTestHandler) 61 | 62 | // Public access 63 | fmt.Println("= Public access") 64 | respA, url := testCookieAccess(t, router, "GET", "/", nil) 65 | assert.Equal(t, 200, respA.Code, "http GET public access") 66 | assert.Equal(t, "/", url, "http GET public access") 67 | 68 | // Login 69 | fmt.Println("= Logins") 70 | // user login 71 | resp, cookie, location := testCookieLogin(t, router, "user", "dogood") 72 | usercookie := cookie 73 | fmt.Println(usercookie) 74 | assert.Equal(t, 200, resp.Code, "http GET success user login") 75 | //assert.Equal(t, true, strings.Contains(resp.Body.String(), "Welcome user"), "http GET success first access user") 76 | //assert.Equal(t, true, strings.Contains(resp.Body.String(), "class=\"navbar-brand\">user"), "http GET success first access user") 77 | // test badlogin 78 | resp, cookie, location = testCookieLogin(t, router, "baduser", "dogood") 79 | assert.Equal(t, 200, resp.Code, "http GET success first user profile") 80 | assert.Equal(t, "/auth/login", location, "Bad login redirect to /auth/login") 81 | 82 | // User access 83 | fmt.Println("= User access") 84 | respA, url = testCookieAccess(t, router, "GET", "/auth/logout", usercookie) 85 | assert.Equal(t, 200, respA.Code, "http GET user access to user profile") 86 | assert.Equal(t, "/", url, "http GET logout success") 87 | fmt.Printf("%+v\n", respA) 88 | 89 | /*respA, url = testCookieAccess(t, router, "GET", "/user/5001", usercookie) 90 | assert.Equal(t, 302, respA.Code, "http GET restrict user access to other profile") 91 | assert.Equal(t, "/auth/logout", url, "http GET restrict user access to other profile") 92 | respA, _ = testCookieAccess(t, router, "GET", "/user/5002", usercookie) 93 | assert.Equal(t, 302, respA.Code, "http GET restrict user access to other profile") 94 | assert.Equal(t, "/auth/logout", url, "http GET restrict user access to other profile") 95 | //fmt.Printf("%+v\n", respA) 96 | */ 97 | } 98 | 99 | func testCookieAccess(t *testing.T, router *gin.Engine, method string, url string, cookie []*http.Cookie) (*httptest.ResponseRecorder, string) { 100 | req, _ := http.NewRequest(method, url, nil) 101 | if cookie != nil { 102 | for _, c := range cookie { 103 | req.Header.Add("Cookie", c.String()) 104 | } 105 | } 106 | resp := httptest.NewRecorder() 107 | router.ServeHTTP(resp, req) 108 | 109 | if resp.Code == 302 { 110 | location, _ := resp.Result().Location() 111 | fmt.Printf("=> Redirect to: %s\n", location.String()) 112 | url = location.String() 113 | cookie := resp.Result().Cookies() 114 | req, _ = http.NewRequest("GET", url, nil) 115 | if len(cookie) != 0 { 116 | for _, c := range cookie { 117 | req.Header.Add("Cookie", c.String()) 118 | } 119 | } 120 | resp = httptest.NewRecorder() 121 | router.ServeHTTP(resp, req) 122 | } 123 | return resp, url 124 | } 125 | 126 | func testCookieLogin(t *testing.T, router *gin.Engine, login string, pass string) (*httptest.ResponseRecorder, []*http.Cookie, string) { 127 | form := url.Values{} 128 | form.Add("username", login) 129 | form.Add("password", pass) 130 | req, err := http.NewRequest("POST", "/auth/login", strings.NewReader(form.Encode())) 131 | req.PostForm = form 132 | req.Header.Add("Content-Type", "application/x-www-form-Urlencoded") 133 | if err != nil { 134 | fmt.Println(err) 135 | } 136 | resp := httptest.NewRecorder() 137 | router.ServeHTTP(resp, req) 138 | //fmt.Printf("%+v\n",resp) 139 | if login == "" || pass == "" { 140 | return resp, nil, "" 141 | } 142 | assert.Equal(t, 302, resp.Code, "http POST success redirect to Edit") 143 | location, _ := resp.Result().Location() 144 | fmt.Printf("=> Redirect to: %s\n", location.String()) 145 | cookie := resp.Result().Cookies() 146 | req, _ = http.NewRequest("GET", location.String(), nil) 147 | for _, c := range cookie { 148 | req.Header.Add("Cookie", c.String()) 149 | } 150 | resp = httptest.NewRecorder() 151 | router.ServeHTTP(resp, req) 152 | 153 | return resp, cookie, location.String() 154 | } 155 | -------------------------------------------------------------------------------- /helpers/cookies.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "github.com/gin-contrib/sessions" 10 | "github.com/gin-contrib/sessions/cookie" 11 | 12 | "github.com/gorilla/securecookie" 13 | ) 14 | 15 | var blockKey = securecookie.GenerateRandomKey(32) 16 | 17 | var CookieSessionName = "appsession" 18 | 19 | func SetSession(c *gin.Context, status string) { 20 | session := sessions.Default(c) 21 | session.Set("status", status) 22 | session.Save() //nolint:errcheck // no session 23 | } 24 | 25 | func MiddlewareSession(secure bool) gin.HandlerFunc { 26 | // store := cookie.NewStore([]byte(secret)) 27 | store := cookie.NewStore(blockKey) 28 | store.Options(sessions.Options{ 29 | //Domain: "localhost", 30 | Path: "/auth/", 31 | HttpOnly: true, 32 | Secure: secure, 33 | MaxAge: 3600, 34 | SameSite: http.SameSiteStrictMode, 35 | }) 36 | return sessions.Sessions(CookieSessionName, store) 37 | } 38 | 39 | func GetUserID(c *gin.Context) (userName string, userId string) { 40 | s := GetSession(c) 41 | return s.User, s.UserID 42 | } 43 | 44 | func GetSession(c *gin.Context) Status { 45 | session := sessions.Default(c) 46 | var s = Status{} 47 | t := session.Get("status") 48 | if t != nil { 49 | s = StrToStatus(t.(string)) 50 | } 51 | return s 52 | } 53 | 54 | func ClearSession(c *gin.Context) { 55 | cookie := &http.Cookie{ 56 | Name: CookieSessionName, 57 | Value: "", 58 | Path: "/auth/", 59 | MaxAge: -1, 60 | } 61 | 62 | http.SetCookie(c.Writer, cookie) 63 | } 64 | 65 | // Encodage de la valeur du cookie. 66 | func encode(value string) string { 67 | encode := &url.URL{Path: value} 68 | return encode.String() 69 | } 70 | 71 | // Décodage de la valeur du cookie. 72 | func decode(value string) string { 73 | decode, _ := url.QueryUnescape(value) 74 | return decode 75 | } 76 | 77 | func SetFlashCookie(c *gin.Context, name string, value string) { 78 | cookie := &http.Cookie{ 79 | Name: name, 80 | Value: encode(value), 81 | SameSite: http.SameSiteStrictMode, 82 | Path: "/", 83 | MaxAge: 1, 84 | } 85 | 86 | http.SetCookie(c.Writer, cookie) 87 | } 88 | 89 | func GetFlashCookie(c *gin.Context, name string) (value string) { 90 | cookie, err := c.Request.Cookie(name) 91 | 92 | var cookieValue string 93 | if err == nil { 94 | cookieValue = cookie.Value 95 | } 96 | 97 | return decode(cookieValue) 98 | } 99 | -------------------------------------------------------------------------------- /helpers/db.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | 7 | "fmt" 8 | "io" 9 | "io/fs" 10 | "os" 11 | "path/filepath" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | . "glauth-ui-light/config" 18 | 19 | "github.com/hydronica/toml" 20 | ) 21 | 22 | func copyfile(src, dst string) (int64, error) { 23 | sourceFileStat, err := os.Stat(src) 24 | if err != nil { 25 | return 0, err 26 | } 27 | 28 | if !sourceFileStat.Mode().IsRegular() { 29 | return 0, fmt.Errorf("%s is not a regular file", src) 30 | } 31 | 32 | source, err := os.Open(src) 33 | if err != nil { 34 | return 0, err 35 | } 36 | defer source.Close() 37 | 38 | destination, err := os.Create(dst) 39 | if err != nil { 40 | return 0, err 41 | } 42 | defer destination.Close() 43 | nBytes, err := io.Copy(destination, source) 44 | return nBytes, err 45 | } 46 | 47 | func findnext(file string) (int, error) { 48 | rxExt := regexp.MustCompile(`\.(\d+)$`) 49 | root := filepath.Dir(file) 50 | 51 | next := 0 52 | err := filepath.WalkDir(root, func(s string, d fs.DirEntry, e error) error { 53 | if e != nil { 54 | return e 55 | } 56 | if strings.Contains(s, file) { 57 | matches := rxExt.FindStringSubmatch(s) 58 | if matches != nil { 59 | i, _ := strconv.Atoi(matches[1]) 60 | if i > next { 61 | next = i 62 | } 63 | } 64 | } 65 | return nil 66 | }) 67 | if err != nil { 68 | return 0, err 69 | } 70 | return next + 1, nil 71 | } 72 | 73 | func WriteDB(cfgw *WebConfig, data Ctmp, username string) error { 74 | _, head, err := ReadDB(cfgw) 75 | if err != nil { 76 | return err 77 | } 78 | // fmt.Println(strings.Join(head, "\n")) 79 | next, e := findnext(cfgw.DBfile) 80 | if e != nil { 81 | return e 82 | } 83 | 84 | _, err = copyfile(cfgw.DBfile, fmt.Sprintf("%s.%d", cfgw.DBfile, next)) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | file, err := os.OpenFile(cfgw.DBfile, os.O_RDWR, 0o640) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | out := Ctmp{Users: data.Users, Groups: data.Groups} 95 | buf := new(bytes.Buffer) 96 | err = toml.NewEncoder(buf).Encode(out) 97 | if err != nil { 98 | return err 99 | } 100 | // fmt.Println(buf.String()) 101 | 102 | currentTime := time.Now() 103 | var top []string 104 | top = append(top, fmt.Sprintf("# Updated by %s on %s", username, currentTime.Format(time.RFC3339))) 105 | top = append(top, head...) 106 | top = append(top, buf.String()) 107 | newdata := []byte(strings.Join(top, "\n")) 108 | if err = os.WriteFile(cfgw.DBfile, newdata, 0o640); err != nil { //nolint:gosec //gosec cant't read octal perm 109 | file.Close() 110 | return err 111 | } 112 | return file.Close() 113 | } 114 | 115 | func ReadDB(cfgw *WebConfig) (Ctmp, []string, error) { 116 | file, err := os.Open(cfgw.DBfile) 117 | if err != nil { 118 | file.Close() 119 | return Ctmp{}, nil, fmt.Errorf("Non-existent config path: %s", cfgw.DBfile) 120 | } 121 | 122 | var headfile []string 123 | // Start reading from the file using a scanner. 124 | scanner := bufio.NewScanner(file) 125 | for scanner.Scan() { 126 | line := scanner.Text() 127 | if strings.HasPrefix(line, "[[users]]") { 128 | break 129 | } 130 | headfile = append(headfile, line) 131 | } 132 | 133 | cfg := Config{} 134 | // var md toml.MetaData 135 | _, err = toml.DecodeFile(cfgw.DBfile, &cfg) 136 | // md, err = toml.DecodeFile(configFileLocation, &cfg) 137 | if err != nil { 138 | file.Close() 139 | return Ctmp{}, nil, err 140 | } 141 | 142 | /* 143 | will need to patch toml for customattributes: 144 | add to `decode_meta.go`: 145 | 146 | ``` 147 | func (md *MetaData) Mappings() map[string]interface{} { 148 | return md.mapping 149 | } 150 | ``` 151 | 152 | switch users := md.Mappings()["users"].(type) { 153 | case []map[string]interface{}: 154 | for _, mduser := range users { 155 | if mduser["customattributes"] != nil { 156 | for idx, cfguser := range cfg.Users { 157 | if cfguser.Name == mduser["name"].(string) { 158 | switch attributes := mduser["customattributes"].(type) { 159 | case []map[string]interface{}: 160 | cfg.Users[idx].CustomAttrs = attributes[0] 161 | case map[string]interface{}: 162 | cfg.Users[idx].CustomAttrs = attributes 163 | default: 164 | log.Println("Unknown attribute structure in config file", "attributes", attributes) 165 | } 166 | break 167 | } 168 | } 169 | } 170 | } 171 | } 172 | */ 173 | // b, _ := json.MarshalIndent(&cfg, "", " ") 174 | // log.Print(string(b)) 175 | data := Ctmp{Users: cfg.Users, Groups: cfg.Groups} 176 | return data, headfile, file.Close() 177 | } 178 | -------------------------------------------------------------------------------- /helpers/db_test.go: -------------------------------------------------------------------------------- 1 | //nolint 2 | package helpers 3 | 4 | import ( 5 | "bufio" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/google/go-cmp/cmp" 13 | 14 | "github.com/stretchr/testify/assert" 15 | 16 | . "glauth-ui-light/config" 17 | ) 18 | 19 | // tools 20 | 21 | func deleteFile(file string) { 22 | // delete file 23 | var err = os.Remove(file) 24 | if err != nil { 25 | fmt.Println(err.Error()) 26 | os.Exit(0) 27 | } 28 | } 29 | 30 | func copyTmpFile(source, dest string) { 31 | content, _ := ioutil.ReadFile(source) 32 | ioutil.WriteFile(dest, content, 0640) 33 | } 34 | 35 | func clean(file string) { 36 | deleteFile(file + ".1") 37 | copyTmpFile(file+".orig", file) 38 | deleteFile(file) 39 | } 40 | 41 | func lazyContains(s []string, e string) bool { 42 | for _, a := range s { 43 | if strings.Contains(strings.ReplaceAll(a, " ", ""), strings.ReplaceAll(e, " ", "")) { 44 | return true 45 | } 46 | } 47 | return false 48 | } 49 | 50 | func readFile(f string) []string { 51 | var origfile []string 52 | file, _ := os.Open(f) 53 | // Start reading from the file using a scanner. 54 | scanner := bufio.NewScanner(file) 55 | read := false 56 | for scanner.Scan() { 57 | line := scanner.Text() 58 | 59 | if !read && strings.HasPrefix(line, "[[users]]") { 60 | read = true 61 | } 62 | if read { 63 | origfile = append(origfile, line) 64 | } 65 | } 66 | file.Close() 67 | return origfile 68 | } 69 | 70 | // Tests 71 | 72 | func TestDB(t *testing.T) { 73 | 74 | cfg := WebConfig{ 75 | DBfile: "_sample-simple.cfg", 76 | Locale: Locale{ 77 | Lang: "en", 78 | Path: "../routes/", 79 | }, 80 | Debug: true, 81 | Tests: true, 82 | CfgUsers: CfgUsers{ 83 | Start: 5000, 84 | GIDAdmin: 6501, 85 | GIDcanChgPass: 6500, 86 | }, 87 | PassPolicy: PassPolicy{ 88 | AllowReadSSHA256: true, 89 | }, 90 | } 91 | copyTmpFile(cfg.DBfile+".orig", cfg.DBfile) 92 | 93 | defer clean(cfg.DBfile) 94 | 95 | origfile := readFile(cfg.DBfile) 96 | //fmt.Printf("=== orig data ===\n%s\n", strings.Join(origfile, "\n")) 97 | 98 | data, head, _ := ReadDB(&cfg) 99 | WriteDB(&cfg, data, "test") 100 | newfile := readFile(cfg.DBfile) 101 | //fmt.Printf("\n=== new data ===\n%s\n============\n", strings.Join(newfile, "\n")) 102 | 103 | // Missing User CustomAttrs management 104 | except := []string{ 105 | `[[users.customattributes]]`, 106 | `employeetype = ["Intern", "Temp"]`, 107 | `employeenumber = [12345, 54321]`, 108 | } 109 | 110 | for _, ol := range origfile { 111 | if !lazyContains(newfile, ol) { 112 | if !strings.Contains(ol, "#") { 113 | fmt.Printf(">%s\n", strings.ReplaceAll(ol, " ", "")) 114 | assert.Equal(t, true, lazyContains(except, ol), ol) 115 | } 116 | } 117 | } 118 | 119 | data2, head2, _ := ReadDB(&cfg) 120 | assert.Equal(t, true, strings.Contains(head2[0], "# Updated by test on "), "head2 updated header") 121 | assert.Equal(t, true, cmp.Equal(head, head2[1:]), "same heads") 122 | assert.Equal(t, true, cmp.Equal(data, data2), "same data") 123 | } 124 | -------------------------------------------------------------------------------- /helpers/fail_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFailLimiter(t *testing.T) { 12 | var s Status 13 | // fmt.Printf(" json string: %+v\n", s.ToJSONStr()) 14 | 15 | /** 16 | Test unlock with counter 17 | **/ 18 | for i := 1; i < 3; i++ { 19 | s = FailLimiter(s, 10) 20 | // fmt.Printf("%+v\n", s) 21 | assert.Equal(t, true, (s.Lock == false && s.Count == i), "2 fails doesn't lock") 22 | time.Sleep(1 * time.Second) 23 | } 24 | 25 | /** 26 | Test method ToJSONStr, fonction StrToStatus 27 | **/ 28 | str := s.ToJSONStr() 29 | fmt.Printf(" json string: %+v\n", str) 30 | o := StrToStatus(str) 31 | fmt.Printf(" json to Status: %+v\n", StrToStatus(str)) 32 | assert.Equal(t, false, o.Lock, "convert string to object") 33 | 34 | o = StrToStatus("") 35 | fmt.Printf(" json to Status: %+v\n", StrToStatus("")) 36 | assert.Equal(t, false, o.Lock, "default object Lock value") 37 | assert.Equal(t, int64(0), o.LastSeen, "default object LastSeen value") 38 | assert.Equal(t, 0, o.Count, "default object Count value") 39 | 40 | /** 41 | Test unlock && reset count, 11 sec later 42 | **/ 43 | fmt.Println("wait 11s ...") 44 | time.Sleep(11 * time.Second) 45 | for i := 1; i < 6; i++ { 46 | s = FailLimiter(s, 10) 47 | fmt.Printf("%+v\n", s) 48 | if i <= 3 { 49 | assert.Equal(t, true, (s.Lock == false && s.Count == i), "Count reset && 3 fails doesn't lock") 50 | } else { 51 | assert.Equal(t, true, (s.Lock == true && s.Count == 0), "Lock && count = 0") 52 | } 53 | time.Sleep(1 * time.Second) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /helpers/global_test.go: -------------------------------------------------------------------------------- 1 | //nolint 2 | package helpers 3 | 4 | /** 5 | Common functions for helpers tests 6 | **/ 7 | 8 | import ( 9 | "fmt" 10 | "html/template" 11 | "net/http" 12 | "net/http/httptest" 13 | "net/url" 14 | "strings" 15 | "testing" 16 | 17 | "github.com/gin-gonic/gin" 18 | "github.com/kataras/i18n" 19 | "github.com/stretchr/testify/assert" 20 | 21 | . "glauth-ui-light/config" 22 | ) 23 | 24 | // tools 25 | 26 | var Data Ctmp 27 | 28 | func resetData() { 29 | Data.Users = []User{} 30 | Data.Groups = []Group{} 31 | } 32 | 33 | // testAccessSimple : access without cookie 34 | func testAccessSimple(t *testing.T, router *gin.Engine, method string, url string) (*httptest.ResponseRecorder, string) { 35 | req, _ := http.NewRequest(method, url, nil) 36 | resp := httptest.NewRecorder() 37 | router.ServeHTTP(resp, req) 38 | 39 | if resp.Code == 302 { 40 | location, _ := resp.Result().Location() 41 | fmt.Printf("=> Redirect to: %s\n", location.String()) 42 | url = location.String() 43 | cookie := resp.Result().Cookies() 44 | req, _ = http.NewRequest("GET", url, nil) 45 | if len(cookie) != 0 { 46 | for _, c := range cookie { 47 | req.Header.Add("Cookie", c.String()) 48 | } 49 | } 50 | resp = httptest.NewRecorder() 51 | router.ServeHTTP(resp, req) 52 | } 53 | return resp, url 54 | } 55 | 56 | // testAccess with cookie from login 57 | func testAccess(t *testing.T, router *gin.Engine, method string, testurl string) (*httptest.ResponseRecorder, string) { 58 | req, _ := http.NewRequest(method, testurl, nil) 59 | resp := httptest.NewRecorder() 60 | router.ServeHTTP(resp, req) 61 | 62 | if resp.Code == 302 { 63 | location, _ := resp.Result().Location() 64 | fmt.Printf("=> Redirect to: %s\n", location.String()) 65 | testurl = location.String() 66 | req, _ = http.NewRequest("GET", testurl, nil) 67 | resp = httptest.NewRecorder() 68 | router.ServeHTTP(resp, req) 69 | } 70 | return resp, testurl 71 | } 72 | 73 | // testLogin 74 | func testLogin(t *testing.T, router *gin.Engine, login string, pass string) *httptest.ResponseRecorder { 75 | form := url.Values{} 76 | form.Add("username", login) 77 | form.Add("password", pass) 78 | req, err := http.NewRequest("POST", "/auth/login", strings.NewReader(form.Encode())) 79 | req.PostForm = form 80 | req.Header.Add("Content-Type", "application/x-www-form-Urlencoded") 81 | if err != nil { 82 | fmt.Println(err) 83 | } 84 | resp := httptest.NewRecorder() 85 | router.ServeHTTP(resp, req) 86 | // fmt.Printf("%+v\n",resp) 87 | if login == "" || pass == "" { 88 | return resp 89 | } 90 | assert.Equal(t, 302, resp.Code, "http POST success redirect to Edit") 91 | newurl, _ := resp.Result().Location() 92 | fmt.Printf("=> Redirect to: %s\n", newurl.String()) 93 | cookie := resp.Result().Cookies() 94 | fmt.Printf("=> Cookie: %+v\n", cookie[0]) 95 | if strings.Contains(cookie[0].String(), "session") { 96 | req, _ = http.NewRequest("GET", newurl.String(), nil) 97 | for _, c := range cookie { 98 | req.Header.Add("Cookie", c.String()) 99 | } 100 | resp = httptest.NewRecorder() 101 | router.ServeHTTP(resp, req) 102 | } 103 | return resp 104 | } 105 | 106 | // mock routes function 107 | 108 | func setConfigTest(cfg WebConfig) gin.HandlerFunc { 109 | return func(c *gin.Context) { 110 | c.Set("Cfg", cfg) 111 | c.Next() 112 | } 113 | } 114 | 115 | func SetUserTest(login string, loginID string, role string) gin.HandlerFunc { 116 | return func(c *gin.Context) { 117 | c.Set("Login", login) 118 | c.Set("LoginID", loginID) 119 | c.Set("Role", role) 120 | c.Next() 121 | } 122 | } 123 | 124 | func InitRouterTest(cfg WebConfig) *gin.Engine { 125 | r := gin.New() 126 | r.Use(gin.Logger()) 127 | r.Use(gin.Recovery()) 128 | basePath := cfg.Locale.Path 129 | 130 | r.Use(MiddlewareSession(false)) 131 | 132 | var err error 133 | I18n, err = i18n.New(i18n.Glob(basePath+"/*/*"), cfg.Locale.Langs...) 134 | if err != nil { 135 | panic(err) 136 | } 137 | 138 | translateLangFunc := func(x string) string { return Tr(cfg.Locale.Lang, x) } 139 | 140 | r.SetFuncMap(template.FuncMap{ 141 | "tr": translateLangFunc, 142 | }) 143 | r.LoadHTMLGlob("../routes/web/templates/**/*.tmpl") 144 | 145 | r.GET("/", func(c *gin.Context) { 146 | c.HTML(http.StatusOK, "home/index.tmpl", gin.H{"appname": cfg.AppName, "appdesc": cfg.AppDesc}) 147 | }) 148 | r.Static("/css", basePath+"/web/assets/css") 149 | r.Static("/fonts", basePath+"/web/assets/fonts") 150 | r.Static("/js", basePath+"/web/assets/js") 151 | 152 | r.Use(setConfigTest(cfg)) 153 | return r 154 | 155 | } 156 | 157 | // some default test values 158 | 159 | func initUsersValues() { 160 | v1 := User{ 161 | Name: "user1", 162 | UIDNumber: 5000, 163 | PrimaryGroup: 6501, 164 | PassSHA256: "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a", 165 | } 166 | Data.Users = append(Data.Users, v1) 167 | v2 := User{ 168 | Name: "user2", 169 | UIDNumber: 5001, 170 | PrimaryGroup: 6504, 171 | OtherGroups: []int{6501, 6503}, 172 | } 173 | Data.Users = append(Data.Users, v2) 174 | v3 := User{ 175 | Name: "serviceapp", 176 | UIDNumber: 5002, 177 | PrimaryGroup: 6502, 178 | PassSHA256: "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a", 179 | } 180 | Data.Users = append(Data.Users, v3) 181 | g1 := Group{ 182 | Name: "group1", 183 | GIDNumber: 6502, 184 | } 185 | Data.Groups = append(Data.Groups, g1) 186 | g2 := Group{ 187 | Name: "group2", 188 | GIDNumber: 6503, 189 | } 190 | Data.Groups = append(Data.Groups, g2) 191 | } 192 | -------------------------------------------------------------------------------- /helpers/i18n.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "github.com/kataras/i18n" 5 | ) 6 | 7 | var I18n *i18n.I18n 8 | 9 | func Tr(lang string, x string, o ...interface{}) string { 10 | res := I18n.Tr(lang, x, o...) 11 | if res != "" { 12 | return res 13 | } 14 | return x 15 | } 16 | -------------------------------------------------------------------------------- /helpers/login_test.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/gin-gonic/gin" 8 | 9 | "glauth-ui-light/config" 10 | ) 11 | 12 | func GetUserByName(name string) (config.User, error) { 13 | for k := range Data.Users { 14 | if Data.Users[k].Name == name { 15 | return Data.Users[k], nil 16 | } 17 | } 18 | return config.User{}, fmt.Errorf("unknown user") 19 | } 20 | 21 | func LoginTestHandlerForm(c *gin.Context) { 22 | cfg := c.MustGet("Cfg").(config.WebConfig) 23 | userName, userId := GetUserID(c) 24 | 25 | c.HTML(200, "home/login.tmpl", gin.H{ 26 | "userName": userName, 27 | "userId": userId, 28 | "currentPage": "login", 29 | "appname": cfg.AppName, 30 | "warning": GetFlashCookie(c, "warning"), 31 | "error": GetFlashCookie(c, "error"), 32 | }) 33 | } 34 | 35 | func LoginTestHandler(c *gin.Context) { 36 | cfg := c.MustGet("Cfg").(config.WebConfig) 37 | fmt.Println(" - POST /login") 38 | lang := cfg.Locale.Lang 39 | 40 | s := GetSession(c) 41 | s = FailLimiter(s, 30) // lock 30s after 4 failed logins 42 | 43 | username := c.PostForm("username") 44 | password := c.PostForm("password") 45 | 46 | switch { 47 | case s.Lock: // == true 48 | s.User = username 49 | s.UserID = "" 50 | SetSession(c, s.ToJSONStr()) 51 | fmt.Println(" - Lock Status for ", username) 52 | SetFlashCookie(c, "error", Tr(lang, "Too many errors, come back later")) 53 | c.Redirect(302, "/auth/login") 54 | case username != "" && password != "": 55 | valid := false 56 | u, err := GetUserByName(username) 57 | if err == nil { 58 | valid = u.ValidPass(password, cfg.PassPolicy.AllowReadSSHA256) 59 | } else { 60 | fmt.Println(" - No user ", username) 61 | } 62 | /*if *backend == "test" { 63 | valid = testValidateUser(username, password) 64 | } 65 | if *backend == "ldap" { 66 | valid = ldapValidateUser(username, password, config) 67 | }*/ 68 | if valid { 69 | tmpid := strconv.Itoa(u.UIDNumber) 70 | s.User = username 71 | s.UserID = tmpid 72 | s.Count = 0 73 | 74 | SetSession(c, s.ToJSONStr()) 75 | c.Redirect(302, "/user/"+tmpid) 76 | } else { 77 | fmt.Println(" - AUTHENTICATION failed for ", username) 78 | s.User = username 79 | s.UserID = "" 80 | SetSession(c, s.ToJSONStr()) 81 | SetFlashCookie(c, "warning", Tr(lang, "Bad credentials")) 82 | c.Redirect(302, "/auth/login") 83 | } 84 | default: 85 | fmt.Println(" - Bad Post params") 86 | c.HTML(404, "home/login.tmpl", nil) 87 | } 88 | } 89 | 90 | func LogoutTestHandler(c *gin.Context) { 91 | cfg := c.MustGet("Cfg").(config.WebConfig) 92 | lang := cfg.Locale.Lang 93 | 94 | ClearSession(c) 95 | SetFlashCookie(c, "success", Tr(lang, "You are disconnected")) 96 | c.Redirect(302, "/") 97 | } 98 | -------------------------------------------------------------------------------- /helpers/sessions.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | /* Session management */ 9 | 10 | // Status : session Object. 11 | type Status struct { 12 | Lock bool `json:"lock"` 13 | LastSeen int64 `json:"lastseen"` 14 | Count int `json:"count"` 15 | User string `json:"user"` 16 | UserID string `json:"userid"` 17 | ReqOTP bool `json:"reqotp"` 18 | } 19 | 20 | // ToJSONStr Status object to string. 21 | func (s *Status) ToJSONStr() string { 22 | b, _ := json.Marshal(s) 23 | return string(b) 24 | } 25 | 26 | // StrToStatus un serialize to Status object. 27 | func StrToStatus(str string) Status { 28 | var r Status 29 | json.Unmarshal([]byte(str), &r) //nolint:errcheck // return empty status 30 | return r 31 | } 32 | 33 | // FailLimiter : Lock for timeLimit seconds after 4 attempts. 34 | func FailLimiter(s Status, timeLimit int64) Status { 35 | // var timeLimit int64 = 30 36 | now := time.Now().UTC().Unix() 37 | ret := s 38 | ret.LastSeen = now 39 | if s.Lock { 40 | if now-s.LastSeen > timeLimit { 41 | ret.Lock = false 42 | ret.Count = 1 43 | } 44 | } else { 45 | ret.Count++ 46 | if now-s.LastSeen > timeLimit { 47 | ret.Count = 1 48 | } 49 | if ret.Count > 3 { 50 | ret.Lock = true 51 | ret.Count = 0 52 | } 53 | } 54 | return ret 55 | } 56 | -------------------------------------------------------------------------------- /img/1-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/img/1-home.png -------------------------------------------------------------------------------- /img/2-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/img/2-login.png -------------------------------------------------------------------------------- /img/3-changepass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/img/3-changepass.png -------------------------------------------------------------------------------- /img/3-otp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/img/3-otp.png -------------------------------------------------------------------------------- /img/4-usersedit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/img/4-usersedit.png -------------------------------------------------------------------------------- /img/4-userspage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/img/4-userspage.png -------------------------------------------------------------------------------- /img/5-delgroup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/img/5-delgroup.png -------------------------------------------------------------------------------- /img/6-responsive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/img/6-responsive.png -------------------------------------------------------------------------------- /img/6-responsive2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/img/6-responsive2.png -------------------------------------------------------------------------------- /locales/aaaa/en.yml: -------------------------------------------------------------------------------- 1 | Account: "" 2 | Account disabled: "" 3 | Add: "" 4 | Add group: "" 5 | Add new token: "" 6 | Add user: "" 7 | Admins: "" 8 | Bad character: "" 9 | Bad credentials: "" 10 | Cancel: "" 11 | Can use OTP: "" 12 | Change password: "" 13 | Changes canceled: "" 14 | Change Secret: "" 15 | Changes saved: "" 16 | Change tokens: "" 17 | Change with caution: "" 18 | Close: "" 19 | Code: "" 20 | Commit changes: "" 21 | Confirm password: "" 22 | Connect: "" 23 | Connection: "" 24 | Create: "" 25 | Create OTP: "" 26 | Create secret: "" 27 | Data locked by admin.: "" 28 | Delete: "" 29 | Delete token: "" 30 | Disabled: "" 31 | Distinct: "" 32 | Edit: "" 33 | Edit group: "" 34 | Edit user: "" 35 | Enabled: "" 36 | Error: "" 37 | Forbidden LoginShell: "" 38 | Givenname: "" 39 | Group: "" 40 | Group must be empty before being deleted: "" 41 | Groups: "" 42 | Groups page: "" 43 | Homedir: "" 44 | Insecure password: "" 45 | Login: "" 46 | LoginShell: "" 47 | Logout: "" 48 | lower case ASCII characters: "" 49 | Mail: "" 50 | Mandatory: "" 51 | max 16: "" 52 | min 2: "" 53 | Name: "" 54 | Name already used: "" 55 | New password: "" 56 | No: "" 57 | Nothing to cancel: "" 58 | Nothing to save: "" 59 | Only for group: "" 60 | Others: "" 61 | OTP: "" 62 | OTP updated: "" 63 | Password: "" 64 | Passwords mismatch: "" 65 | Password updated: "" 66 | Pending registration: "" 67 | Please enter a valid email address: "" 68 | Profile: "" 69 | Reset: "" 70 | Secret: "" 71 | Show QR code: "" 72 | Special groups: "" 73 | SSHkeys: "" 74 | Submit: "" 75 | Surname: "" 76 | Tokens: "" 77 | Tokens changed: "" 78 | Tokens to bypass OTP for applications with registered password: "" 79 | Too long: "" 80 | Too many errors, come back later: "" 81 | Too short: "" 82 | Unknown group: "" 83 | Unknown user: "" 84 | Update: "" 85 | Username: "" 86 | Users: "" 87 | Users page: "" 88 | Wrong base32: "" 89 | Yes: "" 90 | You are disconnected: "" 91 | -------------------------------------------------------------------------------- /locales/de-DE/de.yml: -------------------------------------------------------------------------------- 1 | Account: "Konto" 2 | Account disabled: "Konto deaktiviert" 3 | Add: "Hinzufügen" 4 | Add group: "Neue Gruppe" 5 | Add new token: "Neuer Token" 6 | Add user: "Neuer Benutzer" 7 | Admins: "Admins" 8 | Bad character: "Ungültiges Zeichen" 9 | Bad credentials: "Ungültige Anmeldedaten" 10 | Cancel: "Abbrechen" 11 | Can use OTP: "Kann OTP verwenden" 12 | Change password: "Passwort ändern" 13 | Changes canceled: "Änderungen verworfen" 14 | Change Secret: "Secret ändern" 15 | Changes saved: "Änderungen gespeichert" 16 | Change tokens: "Token ändern" 17 | Change with caution: "Nehmen Sie Änderungen mit Bedacht vor!" 18 | Close: "Schließen" 19 | Code: "Code" 20 | Commit changes: "Änderungen anwenden" 21 | Confirm password: "Passwort bestätigen" 22 | Connect: "Anmelden" 23 | Connection: "Anmeldung" 24 | Create: "Erstellen" 25 | Create OTP: "OTP einrichten" 26 | Create secret: "Secret erstellen" 27 | Data locked by admin.: "Daten sind durch Admin gesperrt" 28 | Delete: "Löschen" 29 | Delete token: "Token löschen" 30 | Disabled: "Deaktiviert" 31 | Distinct: "Einzigartig" 32 | Edit: "Bearbeiten" 33 | Edit group: "Gruppe bearbeiten" 34 | Edit user: "Benutzer bearbeiten" 35 | Enabled: "Aktiviert" 36 | Error: "Fehler" 37 | Forbidden LoginShell: "" 38 | Givenname: "Vorname" 39 | Group: "Gruppe" 40 | Group must be empty before being deleted: "Gruppe muss zum Löschen leer sein" 41 | Groups: "Gruppen" 42 | Groups page: "Gruppenverwaltung" 43 | Homedir: "" 44 | Insecure password: "Unsicheres Passwort" 45 | Login: "Anmeldename" 46 | LoginShell: "" 47 | Logout: "Abmelden" 48 | lower case ASCII characters: "klein geschriebene ASCII-Zeichen" 49 | Mail: "E-Mail" 50 | Mandatory: "Erforderlich" 51 | max 16: "höchstens 16 Zeichen" 52 | min 2: "mindestens 2 Zeichen" 53 | Name: "Name" 54 | Name already used: "Dieser Name wird bereits verwendet" 55 | New password: "Neues Passwort" 56 | No: "Nein" 57 | Nothing to cancel: "Es gibt nichts abzubrechen" 58 | Nothing to save: "Es gibt nichts zu speichern" 59 | Only for group: "Nur Gruppe" 60 | Others: "Andere" 61 | OTP: "OTP" 62 | OTP updated: "OTP aktualisiert" 63 | Password: "Passwort" 64 | Passwords mismatch: "Passwörter stimmen nicht überein" 65 | Password updated: "Passwort geändert" 66 | Pending registration: "Ausstehende Registrierungsanfragen" 67 | Please enter a valid email address: "Bitte geben Sie eine gültige E-Mail-Adresse an" 68 | Profile: "Profil" 69 | Reset: "Zurücksetzen" 70 | Secret: "Secret" 71 | Show QR code: "QR-Code anzeigen" 72 | Special groups: "Sondergruppen" 73 | Submit: "Übermitteln" 74 | Surname: "Nachname" 75 | Tokens: "Token" 76 | Tokens changed: "Token geändert" 77 | Tokens to bypass OTP for applications with registered password: "Token zur Umgehung von OTP in passwortgeschützten Anwendungen" 78 | Too long: "Zu lang" 79 | Too many errors, come back later: "Zu viele Fehlversuche. Bitte versuche es später erneut." 80 | Too short: "Zu kurz" 81 | Unknown group: "Unbekannte Gruppe" 82 | Unknown user: "Unbekannter Benutzer" 83 | Update: "Aktualisieren" 84 | Username: "Benutzername" 85 | Users: "Benutzer" 86 | Users page: "Benutzerverwaltung" 87 | Wrong base32: "Base32-Kodierung ist ungültig" 88 | Yes: "Ja" 89 | You are disconnected: "Ihre Sitzung wurde beendet." 90 | -------------------------------------------------------------------------------- /locales/en-US/en.yml: -------------------------------------------------------------------------------- 1 | Account: "" 2 | Account disabled: "" 3 | Add: "" 4 | Add group: "" 5 | Add new token: "" 6 | Add user: "" 7 | Admins: "" 8 | Bad character: "" 9 | Bad credentials: "" 10 | Cancel: "" 11 | Can use OTP: "" 12 | Change password: "" 13 | Changes canceled: "" 14 | Change Secret: "" 15 | Changes saved: "" 16 | Change tokens: "" 17 | Change with caution: "" 18 | Close: "" 19 | Code: "" 20 | Commit changes: "" 21 | Confirm password: "" 22 | Connect: "" 23 | Connection: "" 24 | Create: "" 25 | Create OTP: "" 26 | Create secret: "" 27 | Data locked by admin.: "" 28 | Delete: "" 29 | Delete token: "" 30 | Disabled: "" 31 | Distinct: "" 32 | Edit: "" 33 | Edit group: "" 34 | Edit user: "" 35 | Enabled: "" 36 | Error: "" 37 | Forbidden LoginShell: "" 38 | Givenname: "" 39 | Group: "" 40 | Group must be empty before being deleted: "" 41 | Groups: "" 42 | Groups page: "" 43 | Homedir: "" 44 | Insecure password: "" 45 | Login: "" 46 | LoginShell: "" 47 | Logout: "" 48 | lower case ASCII characters: "" 49 | Mail: "" 50 | Mandatory: "" 51 | max 16: "" 52 | min 2: "" 53 | Name: "" 54 | Name already used: "" 55 | New password: "" 56 | No: "" 57 | Nothing to cancel: "" 58 | Nothing to save: "" 59 | Only for group: "" 60 | Others: "" 61 | OTP: "" 62 | OTP updated: "" 63 | Password: "" 64 | Passwords mismatch: "" 65 | Password updated: "" 66 | Pending registration: "" 67 | Please enter a valid email address: "" 68 | Profile: "" 69 | Reset: "" 70 | Secret: "" 71 | Show QR code: "" 72 | Special groups: "" 73 | Submit: "" 74 | Surname: "" 75 | Tokens: "" 76 | Tokens changed: "" 77 | Tokens to bypass OTP for applications with registered password: "" 78 | Too long: "" 79 | Too many errors, come back later: "" 80 | Too short: "" 81 | Unknown group: "" 82 | Unknown user: "" 83 | Update: "" 84 | Username: "" 85 | Users: "" 86 | Users page: "" 87 | Wrong base32: "" 88 | Yes: "" 89 | You are disconnected: "" 90 | -------------------------------------------------------------------------------- /locales/es-ES/es.yml: -------------------------------------------------------------------------------- 1 | Account: "Cuenta" 2 | Account disabled: "Cuenta deshabilitada" 3 | Add: "Añadir" 4 | Add group: "Añadir Grupo" 5 | Add new token: "Añadir Token" 6 | Add user: "Añadir Usuario" 7 | Admins: "Administradores" 8 | Bad character: "Carácter no válido" 9 | Bad credentials: "Credenciales incorrectos" 10 | Cancel: "Cancelar" 11 | Can use OTP: "Puede usar OTP" 12 | Change password: "Cambiar Contraseña" 13 | Changes canceled: "Cambios no guardados" 14 | Change Secret: "Cambiar Secret" 15 | Changes saved: "Cambios guardados" 16 | Change tokens: "Cambiar tokens" 17 | Change with caution: "Utilizar con precaución" 18 | Close: "Cerrar" 19 | Code: "Código" 20 | Commit changes: "Confirmar Cambios" 21 | Confirm password: "Confirmar Contraseña" 22 | Connect: "Conectar" 23 | Connection: "Conexión" 24 | Create: "Crear" 25 | Create OTP: "Crear OTP" 26 | Create secret: "Crear Secret" 27 | Data locked by admin.: "Datos bloqueados por el administrador" 28 | Delete: "Eliminar" 29 | Delete token: "Eliminar Token" 30 | Disabled: "Deshabilitar" 31 | Distinct: "Distinto" 32 | Edit: "Editar" 33 | Edit group: "Editar Grupo" 34 | Edit user: "Editar Usuario" 35 | Enabled: "Habilitado" 36 | Error: "Error" 37 | Forbidden LoginShell: "" 38 | Givenname: "Nombre" 39 | Group: "Grupo" 40 | Group must be empty before being deleted: "El grupo no puede ser eliminado si no esta vacío" 41 | Groups: "Grupos" 42 | Groups page: "Grupos" 43 | Homedir: "" 44 | Insecure password: "Contraseña insegura" 45 | Login: "Login" 46 | LoginShell: "" 47 | Logout: "Logout" 48 | lower case ASCII characters: "Caracteres ASCII en minúsculas" 49 | Mail: "Correo Electrónico" 50 | Mandatory: "Obligatorio" 51 | max 16: "Máx. 16" 52 | min 2: "Min. 2" 53 | Name: "Nombre" 54 | Name already used: "Nombre ya utilizado" 55 | New password: "Nueva contraseña" 56 | No: "No" 57 | Nothing to cancel: "No hay cambios" 58 | Nothing to save: "No hay cambios" 59 | Only for group: "Solo para grupos" 60 | Others: "Otros" 61 | OTP: "OTP" 62 | OTP updated: "OTP Actualizado" 63 | Password: "Contraseña" 64 | Passwords mismatch: "Las contraseñas no coinciden" 65 | Password updated: "Contraseña actualizada" 66 | Pending registration: "Pendiente registro" 67 | Please enter a valid email address: "Introduce un correo electrónico válido" 68 | Profile: "Perfil" 69 | Reset: "Resetear" 70 | Secret: "Secret" 71 | Show QR code: "Mostrar código QR" 72 | Special groups: "Grupos especiales" 73 | Submit: "Confirmar" 74 | Surname: "Apellido" 75 | Tokens: "Tokens" 76 | Tokens changed: "Tokens cambiados" 77 | Tokens to bypass OTP for applications with registered password: "Tokens para omitir OTP en aplicaciones con contraseña registrada" 78 | Too long: "Demasiado largo" 79 | Too many errors, come back later: "Demasiados errores, vuelve a intentarlo mas tarde" 80 | Too short: "Demasiado corto" 81 | Unknown group: "Grupo desconocido" 82 | Unknown user: "Usuario desconocido" 83 | Update: "Actualizar" 84 | Username: "Nombre de usuario" 85 | Users: "Usuarios" 86 | Users page: "Usuarios" 87 | Wrong base32: "Base32 incorrecto" 88 | Yes: "Si" 89 | You are disconnected: "Estas desconectado" 90 | -------------------------------------------------------------------------------- /locales/fr-FR/fr.yml: -------------------------------------------------------------------------------- 1 | Account: "Compte" 2 | Account disabled: "Compte désactivé" 3 | Add: "Ajouter" 4 | Add group: "Ajout groupe" 5 | Add new token: "Nouveau token" 6 | Add user: "Ajout utilisateur" 7 | Admins: "Administrateurs" 8 | Bad character: "Certains caractères sont interdits" 9 | Bad credentials: "Erreur d'identification" 10 | Cancel: "Annuler" 11 | Can use OTP: "Peuvent utiliser OTP" 12 | Change password: "Changement du mot de passe" 13 | Changes canceled: "Annulation des changements" 14 | Change Secret: "Nouveau secret" 15 | Changes saved: "Enregistrement des changements" 16 | Change with caution: "ATTENTION Ce changement peut provoquer des effets de bords !" 17 | Close: "Fermer" 18 | Code: "" 19 | Commit changes: "Enregistrer" 20 | Confirm password: "Confirmation" 21 | Connect: "Se connecter" 22 | Connection: "Connexion" 23 | Create: "Créer" 24 | Create OTP: "Créer OTP" 25 | Create secret: "Nouveau secret" 26 | Data locked by admin.: "" 27 | Delete: "Supprimer" 28 | Delete token: "Supprimer token" 29 | Disabled: "Désactivé" 30 | Distinct: "Unique" 31 | Edit: "Éditer" 32 | Edit group: "Édition du groupe" 33 | Edit user: "Édition de l'utilisateur" 34 | Enabled: "Activer" 35 | Error: "Erreur" 36 | Forbidden LoginShell: "LoginShell non autorisé" 37 | Givenname: "Prénom" 38 | Group: "Groupe" 39 | Group must be empty before being deleted: "Le groupe est encore utilisé" 40 | Groups: "Groupes" 41 | Groups page: "Gestion des groupes" 42 | Homedir: "" 43 | Insecure password: "Mot de passe trop simple" 44 | Login: "Identifiant" 45 | LoginShell: "" 46 | Logout: "Déconnexion" 47 | lower case ASCII characters: "caractères ASCII minuscules" 48 | Mail: "" 49 | Mandatory: "Impératif" 50 | max 16: "" 51 | min 2: "" 52 | Name: "Nom" 53 | Name already used: "Identifiant déjà utilisé" 54 | New password: "Mot de passe" 55 | No: "Non" 56 | Nothing to cancel: "Il n'y a rien à annuler" 57 | Nothing to save: "Il n'y a rien à sauver" 58 | Only for group: "Seulement pour le groupe" 59 | Others: "Autres groupes" 60 | OTP: "" 61 | OTP updated: "OTP modifié" 62 | Password: "Mot de passe" 63 | Passwords mismatch: "Les mots de passe sont différents" 64 | Password updated: "Mot de passe modifié" 65 | Pending registration: "Enregistrements en attente" 66 | Please enter a valid email address: "Adresse mail non valide" 67 | Profile: "Profil" 68 | Reset: "" 69 | Secret: "" 70 | Show QR code: "QR Code" 71 | Special groups: "Groupes spéciaux" 72 | Submit: "Envoyer" 73 | Surname: "Nom" 74 | Tokens: "" 75 | Tokens changed: "Tokens enregistrés" 76 | Tokens to bypass OTP for applications with registered password: "Tokens pour les applications qui enregistrent les mots de passe sans utiliser TOTP" 77 | Too long: "Trop long" 78 | Too many errors, come back later: "Trop d'erreurs, retentez plus tard" 79 | Too short: "Trop court" 80 | Unknown group: "Groupe inconnu" 81 | Unknown user: "Utilisateur inconnu" 82 | Update: "Modifier" 83 | Username: "Identifiant" 84 | Users: "Utilisateurs" 85 | Users page: "Gestion des utilisateurs" 86 | Wrong base32: "Encodage base32 non valide" 87 | Yes: "Oui" 88 | You are disconnected: "Vous êtes déconnecté" 89 | -------------------------------------------------------------------------------- /locales/tr.yml: -------------------------------------------------------------------------------- 1 | Account: "" 2 | Account disabled: "" 3 | Add: "" 4 | Add group: "" 5 | Add new token: "" 6 | Add user: "" 7 | Admins: "" 8 | Bad character: "" 9 | Bad credentials: "" 10 | Cancel: "" 11 | Can use OTP: "" 12 | Change password: "" 13 | Changes canceled: "" 14 | Change Secret: "" 15 | Changes saved: "" 16 | Change tokens: "" 17 | Change with caution: "" 18 | Close: "" 19 | Code: "" 20 | Commit changes: "" 21 | Confirm password: "" 22 | Connect: "" 23 | Connection: "" 24 | Create: "" 25 | Create OTP: "" 26 | Create secret: "" 27 | Data locked by admin.: "" 28 | Delete: "" 29 | Delete token: "" 30 | Disabled: "" 31 | Distinct: "" 32 | Edit: "" 33 | Edit group: "" 34 | Edit user: "" 35 | Enabled: "" 36 | Error: "" 37 | Forbidden LoginShell: "" 38 | Givenname: "" 39 | Group: "" 40 | Group must be empty before being deleted: "" 41 | Groups: "" 42 | Groups page: "" 43 | Homedir: "" 44 | Insecure password: "" 45 | Login: "" 46 | LoginShell: "" 47 | Logout: "" 48 | lower case ASCII characters: "" 49 | Mail: "" 50 | Mandatory: "" 51 | max 16: "" 52 | min 2: "" 53 | Name: "" 54 | Name already used: "" 55 | New password: "" 56 | No: "" 57 | Nothing to cancel: "" 58 | Nothing to save: "" 59 | Only for group: "" 60 | Others: "" 61 | OTP: "" 62 | OTP updated: "" 63 | Password: "" 64 | Passwords mismatch: "" 65 | Password updated: "" 66 | Pending registration: "" 67 | Please enter a valid email address: "" 68 | Profile: "" 69 | Reset: "" 70 | Secret: "" 71 | Show QR code: "" 72 | Special groups: "" 73 | SSHkeys: "" 74 | Submit: "" 75 | Surname: "" 76 | Tokens: "" 77 | Tokens changed: "" 78 | Tokens to bypass OTP for applications with registered password: "" 79 | Too long: "" 80 | Too many errors, come back later: "" 81 | Too short: "" 82 | Unknown group: "" 83 | Unknown user: "" 84 | Update: "" 85 | Username: "" 86 | Users: "" 87 | Users page: "" 88 | Wrong base32: "" 89 | Yes: "" 90 | You are disconnected: "" 91 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "io" 7 | "os" 8 | "time" 9 | 10 | "encoding/json" 11 | 12 | flag "github.com/spf13/pflag" 13 | 14 | "github.com/hydronica/toml" 15 | 16 | "github.com/gin-gonic/gin" 17 | rotatelogs "github.com/lestrrat-go/file-rotatelogs" 18 | "github.com/sirupsen/logrus" 19 | easy "github.com/t-tomalak/logrus-easy-formatter" 20 | 21 | . "glauth-ui-light/config" 22 | "glauth-ui-light/handlers" 23 | "glauth-ui-light/helpers" 24 | "glauth-ui-light/routes" 25 | ) 26 | 27 | var log = logrus.New() 28 | 29 | func confLog(cfg *WebConfig) { 30 | level := logrus.InfoLevel 31 | debug := cfg.Debug 32 | path := cfg.Logs.Path 33 | 34 | if debug { 35 | level = logrus.DebugLevel 36 | } else { 37 | gin.SetMode(gin.ReleaseMode) 38 | } 39 | 40 | log = &logrus.Logger{ 41 | Out: os.Stderr, 42 | Level: level, 43 | Formatter: &easy.Formatter{ 44 | TimestampFormat: time.RFC3339, 45 | LogFormat: "%lvl% - [%time%] %msg%\n", 46 | }, 47 | } 48 | 49 | if path != "" && !debug { 50 | writer, _ := rotatelogs.New( 51 | path+"app.%Y%m%d", 52 | rotatelogs.WithLinkName(path), 53 | rotatelogs.WithRotationTime(time.Duration(24)*time.Hour), 54 | rotatelogs.WithMaxAge(-1), 55 | rotatelogs.WithRotationCount(cfg.Logs.RotationCount), 56 | ) 57 | log.SetOutput(writer) 58 | gin.DefaultWriter = io.MultiWriter(writer) 59 | } 60 | } 61 | 62 | func main() { 63 | var Usage = func() { 64 | fmt.Fprintf(os.Stderr, "\nUsage of %s\n%s\n\n", os.Args[0], handlers.Version) 65 | flag.PrintDefaults() 66 | os.Exit(0) 67 | } 68 | flag.Usage = Usage 69 | 70 | // Parameters 71 | confPtr := flag.StringP("conf", "c", "", "Json config file") 72 | debugPtr := flag.BoolP("debug", "d", false, "Debug mode") 73 | flag.Parse() 74 | 75 | conf := *confPtr 76 | Debug := *debugPtr 77 | 78 | // Load config from file 79 | cfg := WebConfig{} 80 | _, err := toml.DecodeFile(conf, &cfg) 81 | if err != nil { 82 | fmt.Fprintf(os.Stderr, "\nError on mandatory config file:\n %s\n", err) 83 | Usage() 84 | } 85 | 86 | if Debug { 87 | fmt.Println("Config file:") 88 | b, _ := json.MarshalIndent(cfg, "", " ") 89 | fmt.Print(string(b)) 90 | fmt.Println("") 91 | cfg.Debug = true 92 | } else { 93 | gin.SetMode(gin.ReleaseMode) 94 | } 95 | 96 | confLog(&cfg) 97 | handlers.Log = *log //nolint 98 | 99 | DataRead, _, _ := helpers.ReadDB(&cfg) 100 | 101 | handlers.Data = DataRead 102 | 103 | r := routes.SetRoutes(&cfg) 104 | 105 | if cfg.SSL.Crt != "" { 106 | err = r.RunTLS(cfg.Port, cfg.SSL.Crt, cfg.SSL.Key) 107 | } else { 108 | fmt.Println("Server started. Version: " + handlers.Version) 109 | log.Println("Server started. Version: " + handlers.Version) 110 | err = r.Run(cfg.Port) 111 | } 112 | if err != nil { 113 | fmt.Println("Error: " + err.Error()) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /routes/auth_test.go: -------------------------------------------------------------------------------- 1 | //nolint 2 | package routes 3 | 4 | import ( 5 | //"bytes" 6 | //"encoding/json" 7 | "fmt" 8 | //"log" 9 | "net/http" 10 | "net/http/httptest" 11 | "net/url" 12 | //"regexp" 13 | "strings" 14 | "testing" 15 | 16 | "github.com/gin-gonic/gin" 17 | "github.com/stretchr/testify/assert" 18 | 19 | . "glauth-ui-light/config" 20 | . "glauth-ui-light/handlers" 21 | ) 22 | 23 | func initUsersValues() { 24 | v1 := User{ 25 | Name: "user", 26 | UIDNumber: 5000, 27 | PrimaryGroup: 6500, 28 | PassSHA256: "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a", //dogood 29 | } 30 | Data.Users = append(Data.Users, v1) 31 | v2 := User{ 32 | Name: "admin", 33 | UIDNumber: 5001, 34 | PrimaryGroup: 6501, 35 | PassSHA256: "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a", 36 | } 37 | Data.Users = append(Data.Users, v2) 38 | v3 := User{ 39 | Name: "serviceapp", 40 | UIDNumber: 5002, 41 | PrimaryGroup: 6502, 42 | PassSHA256: "6478579e37aff45f013e14eeb30b3cc56c72ccdc310123bcdf53e0333e3f416a", 43 | } 44 | Data.Users = append(Data.Users, v3) 45 | } 46 | 47 | func TestSession(t *testing.T) { 48 | 49 | cfg := WebConfig{ 50 | Locale: Locale{ 51 | Lang: "en", 52 | Path: "../locales", 53 | }, 54 | Debug: true, 55 | Tests: true, 56 | Sec: Sec{ 57 | CSRFrandom: "secret", 58 | }, 59 | CfgUsers: CfgUsers{ 60 | Start: 5000, 61 | GIDAdmin: 6501, 62 | GIDcanChgPass: 6500, 63 | GIDuseOtp: 6501, 64 | }, 65 | PassPolicy: PassPolicy{ 66 | AllowReadSSHA256: true, 67 | }, 68 | } 69 | 70 | initUsersValues() 71 | //fmt.Printf("%+v\n",Data) 72 | gin.SetMode(gin.TestMode) 73 | router := SetRoutes(&cfg) 74 | 75 | // Public access 76 | fmt.Println("= Public access") 77 | respA, url := testAccess(t, router, "GET", "/", nil) 78 | assert.Equal(t, 200, respA.Code, "http GET public access") 79 | assert.Equal(t, "/", url, "http GET public access") 80 | 81 | respA, url = testAccess(t, router, "GET", "/favicon.ico", nil) 82 | headers := respA.Header() 83 | //fmt.Printf("=====\n%+v\n",headers["Cache-Control"][0]) 84 | assert.Equal(t, "public, max-age=604800, immutable", headers["Cache-Control"][0], "http GET cached headers") 85 | 86 | // Login 87 | fmt.Println("= Logins") 88 | // user login 89 | resp, cookie, location := testLogin(t, router, "user", "dogood") 90 | usercookie := cookie 91 | assert.Equal(t, 200, resp.Code, "http GET success user login") 92 | //assert.Equal(t, true, strings.Contains(resp.Body.String(), "Welcome user"), "http GET success first access user") 93 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "> user"), "http GET success first access user") 94 | // test badlogin 95 | resp, cookie, location = testLogin(t, router, "baduser", "dogood") 96 | assert.Equal(t, 200, resp.Code, "http GET success first user profile") 97 | assert.Equal(t, "/auth/login", location, "Bad login redirect to /login") 98 | 99 | // admin login 100 | resp, cookie, location = testLogin(t, router, "admin", "dogood") 101 | admincookie := cookie 102 | assert.Equal(t, 200, resp.Code, "http GET success admin login") 103 | //assert.Equal(t, true, strings.Contains(resp.Body.String(), "Welcome admin"), "http GET success first access admin") 104 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "> admin"), "http GET success first access user") 105 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "id=\"nav-otp\""), "show otp nav") 106 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "id=\"nav-chgpwd\""), "show change password nav") 107 | //fmt.Printf("=====\n%+v\n",resp) 108 | 109 | // serviceapp login 110 | resp, cookie, location = testLogin(t, router, "serviceapp", "dogood") 111 | serviceappcookie := cookie 112 | assert.Equal(t, 200, resp.Code, "http GET success serviceapp login") 113 | //assert.Equal(t, true, strings.Contains(resp.Body.String(), "Welcome serviceapp"), "http GET success first access serviceapp") 114 | assert.Equal(t, true, strings.Contains(resp.Body.String(), "> serviceapp"), "http GET success first access user") 115 | //assert.Equal(t, true, strings.Contains(resp.Body.String(), "id=\"nav-otp\""), "show otp nav") 116 | //assert.Equal(t, true, strings.Contains(resp.Body.String(), "id=\"nav-chgpwd\""), "show change password nav") 117 | 118 | // Admin access 119 | fmt.Println("= Admin access") 120 | Url := "/auth" 121 | respA, url = testAccess(t, router, "GET", Url+"/crud/user/", usercookie) 122 | assert.Equal(t, 302, respA.Code, "http GET bad user access to admin url") 123 | assert.Equal(t, "/auth/logout", url, "http GET bad user access to admin url") 124 | //fmt.Printf("%+v\n", respA) 125 | respA, url = testAccess(t, router, "GET", Url+"/crud/user/", admincookie) 126 | assert.Equal(t, 200, respA.Code, "http GET admin access to admin url") 127 | assert.Equal(t, Url+"/crud/user/", url, "http GET admin access to admin url") 128 | respA, url = testAccess(t, router, "GET", Url+"/user/5001", admincookie) 129 | assert.Equal(t, 200, respA.Code, "http GET admin access to profile") 130 | assert.Equal(t, Url+"/user/5001", url, "http GET admin access to profile") 131 | assert.Equal(t, true, strings.Contains(respA.Body.String(), ">Change password"), "http GET success allow Change pass") 132 | respA, url = testAccess(t, router, "GET", Url+"/user/5000", admincookie) 133 | assert.Equal(t, 200, respA.Code, "http GET admin access to user profile") 134 | assert.Equal(t, Url+"/user/5000", url, "http GET admin access to user profile") 135 | 136 | // User access 137 | fmt.Println("= User access") 138 | respA, _ = testAccess(t, router, "GET", Url+"/user/5000", usercookie) 139 | assert.Equal(t, 200, respA.Code, "http GET user access to user profile") 140 | assert.Equal(t, true, strings.Contains(respA.Body.String(), ">Change password"), "http GET success allow Change pass") 141 | assert.Equal(t, false, strings.Contains(respA.Body.String(), "id=\"nav-otp\""), "show otp nav") 142 | assert.Equal(t, true, strings.Contains(respA.Body.String(), "id=\"nav-chgpwd\""), "show change password nav") 143 | 144 | respA, url = testAccess(t, router, "GET", Url+"/user/5001", usercookie) 145 | assert.Equal(t, 302, respA.Code, "http GET restrict user access to other profile") 146 | assert.Equal(t, "/auth/logout", url, "http GET restrict user access to other profile") 147 | respA, _ = testAccess(t, router, "GET", Url+"/user/5002", usercookie) 148 | assert.Equal(t, 302, respA.Code, "http GET restrict user access to other profile") 149 | assert.Equal(t, "/auth/logout", url, "http GET restrict user access to other profile") 150 | //fmt.Printf("%+v\n", respA) 151 | 152 | // Service access 153 | fmt.Println("= Service access") 154 | respA, _ = testAccess(t, router, "GET", Url+"/user/5002", serviceappcookie) 155 | assert.Equal(t, 200, respA.Code, "http GET user access to serviceapp profile") 156 | assert.Equal(t, false, strings.Contains(respA.Body.String(), ">Change password"), "http GET success don't show Change pass") 157 | //fmt.Printf("%+v\n", respA) 158 | 159 | var oldcookie []*http.Cookie 160 | badcookie := &http.Cookie{ 161 | Name: "session", 162 | Value: "", 163 | Path: Url + "/", 164 | MaxAge: 1, 165 | } 166 | oldcookie = append(oldcookie, badcookie) 167 | 168 | respA, url = testAccess(t, router, "GET", Url+"/user/5002", oldcookie) 169 | assert.Equal(t, 302, respA.Code, "http GET reject access with old or bad cookie") 170 | assert.Equal(t, "/auth/logout", url, "http GET reject access with old or bad cookie") 171 | } 172 | 173 | func testAccess(t *testing.T, router *gin.Engine, method string, url string, cookie []*http.Cookie) (*httptest.ResponseRecorder, string) { 174 | req, _ := http.NewRequest(method, url, nil) 175 | if cookie != nil { 176 | for _, c := range cookie { 177 | req.Header.Add("Cookie", c.String()) 178 | } 179 | } 180 | resp := httptest.NewRecorder() 181 | router.ServeHTTP(resp, req) 182 | 183 | if resp.Code == 302 { 184 | location, _ := resp.Result().Location() 185 | fmt.Printf("=> Redirect to: %s\n", location.String()) 186 | url = location.String() 187 | cookie := resp.Result().Cookies() 188 | req, _ = http.NewRequest("GET", url, nil) 189 | if len(cookie) != 0 { 190 | for _, c := range cookie { 191 | req.Header.Add("Cookie", c.String()) 192 | } 193 | } 194 | resp = httptest.NewRecorder() 195 | router.ServeHTTP(resp, req) 196 | } 197 | return resp, url 198 | } 199 | 200 | func testLogin(t *testing.T, router *gin.Engine, login string, pass string) (*httptest.ResponseRecorder, []*http.Cookie, string) { 201 | form := url.Values{} 202 | form.Add("username", login) 203 | form.Add("password", pass) 204 | req, err := http.NewRequest("POST", "/auth/login", strings.NewReader(form.Encode())) 205 | req.PostForm = form 206 | req.Header.Add("Content-Type", "application/x-www-form-Urlencoded") 207 | if err != nil { 208 | fmt.Println(err) 209 | } 210 | resp := httptest.NewRecorder() 211 | router.ServeHTTP(resp, req) 212 | //fmt.Printf("%+v\n",resp) 213 | if login == "" || pass == "" { 214 | return resp, nil, "" 215 | } 216 | assert.Equal(t, 302, resp.Code, "http POST success redirect to Edit") 217 | location, _ := resp.Result().Location() 218 | fmt.Printf("=> Redirect to: %s\n", location.String()) 219 | cookie := resp.Result().Cookies() 220 | req, _ = http.NewRequest("GET", location.String(), nil) 221 | for _, c := range cookie { 222 | req.Header.Add("Cookie", c.String()) 223 | } 224 | resp = httptest.NewRecorder() 225 | router.ServeHTTP(resp, req) 226 | 227 | return resp, cookie, location.String() 228 | } 229 | -------------------------------------------------------------------------------- /routes/routes.go: -------------------------------------------------------------------------------- 1 | package routes 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "html/template" 7 | "io/fs" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "crypto/md5" //nolint:gosec // only for cache headers 14 | 15 | "github.com/gin-contrib/secure" 16 | "github.com/gin-gonic/gin" 17 | "github.com/kataras/i18n" 18 | 19 | "github.com/gin-contrib/static" 20 | csrf "github.com/utrack/gin-csrf" 21 | 22 | "github.com/ulule/limiter" 23 | mgin "github.com/ulule/limiter/drivers/middleware/gin" 24 | "github.com/ulule/limiter/drivers/store/memory" 25 | 26 | "glauth-ui-light/config" 27 | . "glauth-ui-light/handlers" 28 | . "glauth-ui-light/helpers" 29 | ) 30 | 31 | //go:embed web/assets/* 32 | var server embed.FS 33 | 34 | //go:embed web/templates/* 35 | var templateFs embed.FS 36 | 37 | type embedFileSystem struct { 38 | http.FileSystem 39 | } 40 | 41 | func (e embedFileSystem) Exists(prefix string, path string) bool { 42 | _, err := e.Open(path) 43 | return err == nil 44 | } 45 | 46 | func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem { 47 | fsys, err := fs.Sub(fsEmbed, targetPath) 48 | if err != nil { 49 | panic(err) 50 | } 51 | return embedFileSystem{ 52 | FileSystem: http.FS(fsys), 53 | } 54 | } 55 | 56 | func setConfig(cfg *config.WebConfig) gin.HandlerFunc { 57 | return func(c *gin.Context) { 58 | c.Set("Cfg", *cfg) 59 | c.Next() 60 | } 61 | } 62 | 63 | func secureHeaders() gin.HandlerFunc { 64 | return secure.New(secure.Config{ 65 | FrameDeny: true, 66 | ContentTypeNosniff: true, 67 | BrowserXssFilter: true, 68 | ContentSecurityPolicy: "default-src 'self' 'unsafe-inline'; img-src 'self' data:", 69 | IENoOpen: true, 70 | ReferrerPolicy: "strict-origin-when-cross-origin", 71 | }) 72 | } 73 | 74 | func sslHeaders(port string) gin.HandlerFunc { 75 | return secure.New(secure.Config{ 76 | SSLRedirect: true, 77 | SSLHost: port, 78 | STSSeconds: 315360000, 79 | STSIncludeSubdomains: true, 80 | SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"}, 81 | }) 82 | } 83 | 84 | func initServer(cfg *config.WebConfig) *gin.Engine { 85 | r := gin.New() 86 | 87 | rate, _ := limiter.NewRateFromFormatted("60-M") // 60 reqs/minute 88 | 89 | lStore := memory.NewStore() 90 | limitMiddleware := mgin.NewMiddleware(limiter.New(lStore, rate)) 91 | 92 | // Set log config 93 | if !cfg.Debug { 94 | r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { 95 | // custom format 96 | // return fmt.Sprintf("%s - [%s] \"%s %s %s\" %d \"%s\" %s\n", 97 | return fmt.Sprintf("%s - [%s] \"%s %s %s\" %d %q %s\n", 98 | param.ClientIP, 99 | param.TimeStamp.Format(time.RFC3339), 100 | param.Method, 101 | param.Path, 102 | param.Request.Proto, 103 | param.StatusCode, 104 | param.Request.UserAgent(), 105 | param.ErrorMessage, 106 | ) 107 | })) 108 | } else { 109 | r.Use(gin.Logger()) 110 | } 111 | 112 | r.Use(gin.Recovery()) 113 | 114 | // Set TrustedProxies 115 | r.SetTrustedProxies(cfg.Sec.TrustedProxies) //nolint:errcheck //useless check 116 | 117 | // Find source IP from proxy 118 | r.ForwardedByClientIP = true 119 | 120 | // Limit rate request 121 | r.Use(limitMiddleware) 122 | 123 | // SSL 124 | useSSL := false 125 | if cfg.SSL.Crt != "" { 126 | useSSL = true 127 | } 128 | r.Use(MiddlewareSession(useSSL)) 129 | if useSSL { 130 | r.Use(sslHeaders(cfg.Port)) 131 | } 132 | 133 | // Secure headers 134 | r.Use(secureHeaders()) 135 | 136 | // Load templates 137 | baseLocalesPath := cfg.Locale.Path 138 | 139 | if _, err := os.Stat(baseLocalesPath); !os.IsNotExist(err) { 140 | var err error 141 | I18n, err = i18n.New(i18n.Glob(baseLocalesPath+"/*/*"), cfg.Locale.Langs...) 142 | if err != nil { 143 | fmt.Printf("Warning no locale dir: %s\n", err.Error()) 144 | } 145 | } else { 146 | I18n, _ = i18n.New(i18n.Glob(""), "en") 147 | } 148 | 149 | translateLangFunc := func(x string) string { return Tr(cfg.Locale.Lang, x) } 150 | 151 | r.SetFuncMap(template.FuncMap{ 152 | "tr": translateLangFunc, 153 | }) 154 | 155 | // r.LoadHTMLGlob(basePath + "/web/templates/**/*.tmpl") 156 | t := []string{} 157 | dirs, _ := templateFs.ReadDir("web/templates") 158 | for k := range dirs { 159 | files, _ := templateFs.ReadDir("web/templates/" + dirs[k].Name()) 160 | for f := range files { 161 | t = append(t, fmt.Sprintf("web/templates/%s/%s", dirs[k].Name(), files[f].Name())) 162 | } 163 | } 164 | templ := template.Must(template.New("").Funcs(template.FuncMap{"tr": translateLangFunc}).ParseFS(templateFs, t...)) 165 | r.SetHTMLTemplate(templ) 166 | if cfg.Debug { 167 | fmt.Printf("\nTemplates loaded:\n\t- %s\n", strings.Join(t, "\n\t- ")) 168 | } 169 | 170 | // Load static files 171 | r.GET("/", func(c *gin.Context) { 172 | c.HTML(http.StatusOK, "home/index.tmpl", gin.H{"appname": cfg.AppName, "appdesc": cfg.AppDesc}) 173 | }) 174 | 175 | // Cache static files 176 | r.Use(setCacheHeaders()) 177 | 178 | r.Use(static.Serve("/", EmbedFolder(server, "web/assets"))) 179 | r.Static("/css", "/assets/css") 180 | // r.Static("/fonts", basePath+"/assets/fonts") 181 | r.Static("/js", "/assets/js") 182 | 183 | r.Use(setConfig(cfg)) 184 | 185 | return r 186 | } 187 | 188 | func SetRoutes(cfg *config.WebConfig) *gin.Engine { 189 | r := initServer(cfg) 190 | 191 | mw := csrf.Middleware(csrf.Options{ 192 | Secret: cfg.Sec.CSRFrandom, 193 | ErrorFunc: func(c *gin.Context) { 194 | c.String(400, "CSRF token mismatch") 195 | c.Abort() 196 | }, 197 | }) 198 | 199 | l := r.Group("auth") 200 | l.GET("/login", LoginHandlerForm) 201 | l.POST("/login", LoginHandler) 202 | l.GET("/logout", LogoutHandler) 203 | 204 | u := r.Group("auth/user") 205 | u.Use(mw) 206 | u.Use(Auth("self")) 207 | u.GET("/:id", UserProfile) 208 | u.POST("/:id", UserChgPasswd) 209 | u.POST("/otp/:id", UserChgOTP) 210 | u.POST("/passapp/:id", UserPassApp) 211 | 212 | admin := r.Group("auth/crud") 213 | admin.Use(mw) 214 | admin.Use(Auth("admin")) 215 | admin.GET("/user/", UserList) 216 | admin.GET("/user/:id", UserEdit) 217 | admin.POST("/user/:id", UserUpdate) // for HTML 1.1 form don't have PUT/DELETE methods 218 | // admin.PUT("/:id", UserUpdate) 219 | admin.GET("/user/create", UserAdd) 220 | admin.POST("/user/create", UserCreate) 221 | admin.POST("/user/del/:id", UserDel) // for HTML 1.1 form don't have PUT/DELETE methods 222 | // admin.DELETE("/:id", UserDel) 223 | 224 | admin.GET("/reload", CancelChanges) 225 | admin.GET("/save", SaveChanges) 226 | 227 | admin.GET("/group/", GroupList) 228 | admin.GET("/group/:id", GroupEdit) 229 | admin.POST("/group/:id", GroupUpdate) 230 | admin.GET("/group/create", GroupAdd) 231 | admin.POST("/group/create", GroupCreate) 232 | admin.POST("/group/del/:id", GroupDel) 233 | 234 | return r 235 | } 236 | 237 | func contains(s []int, e int) bool { 238 | for _, a := range s { 239 | if a == e { 240 | return true 241 | } 242 | } 243 | return false 244 | } 245 | 246 | func setCacheHeaders() gin.HandlerFunc { 247 | data := []byte(time.Now().String()) 248 | etag := fmt.Sprintf("%x", md5.Sum(data)) //nolint:gosec //only for cache headers 249 | cacheSince := time.Now().Format(http.TimeFormat) 250 | cacheUntil := time.Now().AddDate(0, 12, 0).Format(http.TimeFormat) 251 | return func(c *gin.Context) { 252 | if strings.Contains(c.Request.URL.Path, "/css/") || 253 | strings.Contains(c.Request.URL.Path, "/js/") || 254 | strings.Contains(c.Request.URL.Path, "favicon") { 255 | c.Header("Cache-Control", "public, max-age=604800, immutable") 256 | c.Header("ETag", etag) 257 | c.Header("Last-Modified", cacheSince) 258 | c.Header("Expires", cacheUntil) 259 | c.Next() 260 | return 261 | } 262 | } 263 | } 264 | 265 | func Auth(rolectl string) gin.HandlerFunc { 266 | return func(c *gin.Context) { 267 | cfg := c.MustGet("Cfg").(config.WebConfig) 268 | GIDcanChgPass := cfg.CfgUsers.GIDcanChgPass 269 | GIDAdmin := cfg.CfgUsers.GIDAdmin 270 | GIDuseOtp := cfg.CfgUsers.GIDuseOtp 271 | username, userid := GetUserID(c) 272 | if username == "" || userid == "" { 273 | Log.Info(fmt.Sprintf("%s -- NOK denied old or bad cookie", c.ClientIP())) 274 | c.Redirect(302, "/auth/logout") 275 | c.Abort() 276 | return 277 | } 278 | id := GetUserKey(userid) 279 | role := "user" 280 | // search admin role 281 | groups := Data.Users[id].OtherGroups 282 | groups = append(groups, Data.Users[id].PrimaryGroup) 283 | if contains(groups, GIDAdmin) { 284 | role = "admin" 285 | Log.Info(fmt.Sprintf("%s -- [%s] is admin", c.ClientIP(), username)) 286 | } 287 | // search allow self change password 288 | c.Set("Csrf", csrf.GetToken(c)) 289 | c.Set("CanChgPass", false) 290 | if contains(groups, GIDcanChgPass) || contains(groups, GIDAdmin) { 291 | c.Set("CanChgPass", true) 292 | } 293 | c.Set("UseOtp", false) 294 | if contains(groups, GIDuseOtp) { 295 | c.Set("UseOtp", true) 296 | } 297 | c.Set("Login", username) 298 | c.Set("LoginID", userid) 299 | c.Set("Role", role) 300 | c.Set("AppName", cfg.AppName) 301 | c.Set("MaskOTP", cfg.MaskOTP) 302 | c.Set("DefaultHomedir", cfg.DefaultHomedir) 303 | c.Set("DefaultLoginShell", cfg.DefaultLoginShell) 304 | Log.Info(fmt.Sprintf("%s -- OK [%s] (%s) valid access", c.ClientIP(), username, userid)) 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /routes/web/assets/css/fonts/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/routes/web/assets/css/fonts/bootstrap-icons.woff -------------------------------------------------------------------------------- /routes/web/assets/css/fonts/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/routes/web/assets/css/fonts/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /routes/web/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yvesago/glauth-ui-light/6384dba7c337b9b9554ea9e43764ad35819b09a8/routes/web/assets/favicon.ico -------------------------------------------------------------------------------- /routes/web/assets/js/Nibbler.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2010-2013 Thomas Peri 3 | http://www.tumuski.com/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | */ 24 | 25 | /*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, 26 | eqeqeq: true, plusplus: true, regexp: true, newcap: true, immed: true */ 27 | // (good parts minus bitwise and strict, plus white.) 28 | 29 | /** 30 | * Nibbler - Multi-Base Encoder 31 | * 32 | * version 2013-04-24 33 | * 34 | * Options: 35 | * dataBits: The number of bits in each character of unencoded data. 36 | * codeBits: The number of bits in each character of encoded data. 37 | * keyString: The characters that correspond to each value when encoded. 38 | * pad (optional): The character to pad the end of encoded output. 39 | * arrayData (optional): If truthy, unencoded data is an array instead of a string. 40 | * 41 | * Example: 42 | * 43 | * var base64_8bit = new Nibbler({ 44 | * dataBits: 8, 45 | * codeBits: 6, 46 | * keyString: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', 47 | * pad: '=' 48 | * }); 49 | * base64_8bit.encode("Hello, World!"); // returns "SGVsbG8sIFdvcmxkIQ==" 50 | * base64_8bit.decode("SGVsbG8sIFdvcmxkIQ=="); // returns "Hello, World!" 51 | * 52 | * var base64_7bit = new Nibbler({ 53 | * dataBits: 7, 54 | * codeBits: 6, 55 | * keyString: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/', 56 | * pad: '=' 57 | * }); 58 | * base64_7bit.encode("Hello, World!"); // returns "kZdmzesQV9/LZkQg==" 59 | * base64_7bit.decode("kZdmzesQV9/LZkQg=="); // returns "Hello, World!" 60 | * 61 | */ 62 | var Nibbler = function (options) { 63 | "use strict"; 64 | 65 | // Code quality tools like jshint warn about bitwise operators, 66 | // because they're easily confused with other more common operators, 67 | // and because they're often misused for doing arithmetic. Nibbler uses 68 | // them properly, though, for moving individual bits, so turn off the warning. 69 | /*jshint bitwise:false */ 70 | 71 | var construct, 72 | 73 | // options 74 | pad, dataBits, codeBits, keyString, arrayData, 75 | 76 | // private instance variables 77 | mask, group, max, 78 | 79 | // private methods 80 | gcd, translate, 81 | 82 | // public methods 83 | encode, decode; 84 | 85 | // pseudo-constructor 86 | construct = function () { 87 | var i, mag, prev; 88 | 89 | // options 90 | pad = options.pad || ''; 91 | dataBits = options.dataBits; 92 | codeBits = options.codeBits; 93 | keyString = options.keyString; 94 | arrayData = options.arrayData; 95 | 96 | // bitmasks 97 | mag = Math.max(dataBits, codeBits); 98 | prev = 0; 99 | mask = []; 100 | for (i = 0; i < mag; i += 1) { 101 | mask.push(prev); 102 | prev += prev + 1; 103 | } 104 | max = prev; 105 | 106 | // ouput code characters in multiples of this number 107 | group = dataBits / gcd(dataBits, codeBits); 108 | }; 109 | 110 | // greatest common divisor 111 | gcd = function (a, b) { 112 | var t; 113 | while (b !== 0) { 114 | t = b; 115 | b = a % b; 116 | a = t; 117 | } 118 | return a; 119 | }; 120 | 121 | // the re-coder 122 | translate = function (input, bitsIn, bitsOut, decoding) { 123 | var i, len, chr, byteIn, 124 | buffer, size, output, 125 | write; 126 | 127 | // append a byte to the output 128 | write = function (n) { 129 | if (!decoding) { 130 | output.push(keyString.charAt(n)); 131 | } else if (arrayData) { 132 | output.push(n); 133 | } else { 134 | output.push(String.fromCharCode(n)); 135 | } 136 | }; 137 | 138 | buffer = 0; 139 | size = 0; 140 | output = []; 141 | 142 | len = input.length; 143 | for (i = 0; i < len; i += 1) { 144 | // the new size the buffer will be after adding these bits 145 | size += bitsIn; 146 | 147 | // read a character 148 | if (decoding) { 149 | // decode it 150 | chr = input.charAt(i); 151 | byteIn = keyString.indexOf(chr); 152 | if (chr === pad) { 153 | break; 154 | } else if (byteIn < 0) { 155 | throw 'the character "' + chr + '" is not a member of ' + keyString; 156 | } 157 | } else { 158 | if (arrayData) { 159 | byteIn = input[i]; 160 | } else { 161 | byteIn = input.charCodeAt(i); 162 | } 163 | if ((byteIn | max) !== max) { 164 | throw byteIn + " is outside the range 0-" + max; 165 | } 166 | } 167 | 168 | // shift the buffer to the left and add the new bits 169 | buffer = (buffer << bitsIn) | byteIn; 170 | 171 | // as long as there's enough in the buffer for another output... 172 | while (size >= bitsOut) { 173 | // the new size the buffer will be after an output 174 | size -= bitsOut; 175 | 176 | // output the part that lies to the left of that number of bits 177 | // by shifting the them to the right 178 | write(buffer >> size); 179 | 180 | // remove the bits we wrote from the buffer 181 | // by applying a mask with the new size 182 | buffer &= mask[size]; 183 | } 184 | } 185 | 186 | // If we're encoding and there's input left over, pad the output. 187 | // Otherwise, leave the extra bits off, 'cause they themselves are padding 188 | if (!decoding && size > 0) { 189 | 190 | // flush the buffer 191 | write(buffer << (bitsOut - size)); 192 | 193 | // add padding string for the remainder of the group 194 | while (output.length % group > 0) { 195 | output.push(pad); 196 | } 197 | } 198 | 199 | // string! 200 | return (arrayData && decoding) ? output : output.join(''); 201 | }; 202 | 203 | /** 204 | * Encode. Input and output are strings. 205 | */ 206 | encode = function (input) { 207 | return translate(input, dataBits, codeBits, false); 208 | }; 209 | 210 | /** 211 | * Decode. Input and output are strings. 212 | */ 213 | decode = function (input) { 214 | return translate(input, codeBits, dataBits, true); 215 | }; 216 | 217 | this.encode = encode; 218 | this.decode = decode; 219 | construct(); 220 | }; 221 | -------------------------------------------------------------------------------- /routes/web/templates/global/footer.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "global/footer.tmpl" }} 2 | 3 | 4 |
5 |
6 | 7 | {{ .appname }} 8 | 9 | 10 | {{ .version }} 11 | 12 |
13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {{ end }} 22 | -------------------------------------------------------------------------------- /routes/web/templates/global/header.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "global/header.tmpl" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 58 | 59 |
60 |
61 | {{ if .success }} 62 | 66 | {{ end }} 67 | 68 | {{ if .warning }} 69 | 73 | {{ end }} 74 | 75 | {{ if .error }} 76 | 80 | {{ end }} 81 | {{ end }} 82 | -------------------------------------------------------------------------------- /routes/web/templates/group/create.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "group/create.tmpl"}} 2 | {{ template "global/header.tmpl" .}} 3 | 4 |
5 | 6 | 7 |
8 | 9 |
10 | 11 |
{{ tr "Mandatory" }}, {{ tr "Distinct" }}, {{ tr "lower case ASCII characters" }}, {{ tr "min 2" }}, {{ tr "max 16" }}
12 | {{ with .u.Errors.Name }} 13 |
{{ . }}
14 | {{ end }} 15 |
16 |
17 | 18 | {{ if .u.Errors }} 19 | 25 | {{ end }} 26 |
27 | 28 | 29 | {{ template "global/footer.tmpl" .}} 30 | {{ end }} 31 | -------------------------------------------------------------------------------- /routes/web/templates/group/edit.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "group/edit.tmpl"}} 2 | {{ template "global/header.tmpl" .}} 3 | 4 |
5 | 6 | 7 |
8 | 9 |
10 | 11 |
{{ tr "Change with caution" }}
12 |
{{ tr "Mandatory" }}, {{ tr "lower case ASCII characters" }}, {{ tr "min 2" }}, {{ tr "max 16" }}
13 | {{ with .u.Errors.Name }} 14 |
{{ . }}
15 | {{ end }} 16 |
17 |
18 |
19 |
20 |
21 | 22 | {{ if .u.Errors }} 23 | 28 | {{ end }} 29 |
30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 | 38 | 58 | 59 | 60 | {{ template "global/footer.tmpl" .}} 61 | {{ end }} 62 | -------------------------------------------------------------------------------- /routes/web/templates/group/list.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "group/list.tmpl"}} 2 | {{ template "global/header.tmpl" .}} 3 | 4 | {{ tr "Add" }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {{ range .groupdata }} 14 | 15 | 20 | 21 | 22 | {{ end }} 23 | 24 |
{{ tr "Name" }}
{{ .Name }} 16 | {{ if eq .Name $.groupsinfo.Admins}} {{ end }} 17 | {{ if eq .Name $.groupsinfo.Users}} {{ end }} 18 | {{ if eq .Name $.groupsinfo.OTP}} {{ end }} 19 | {{ tr "Edit" }}
25 | 26 |

27 |

35 |

36 | 37 | {{ template "global/footer.tmpl" .}} 38 | {{ end }} 39 | -------------------------------------------------------------------------------- /routes/web/templates/home/error.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "home/error.tmpl"}} 2 | {{ template "global/header.tmpl" .}} 3 | 4 |

{{ tr "Error" }}

5 | 6 | {{ template "global/footer.tmpl" .}} 7 | {{ end }} 8 | 9 | -------------------------------------------------------------------------------- /routes/web/templates/home/index.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "home/index.tmpl"}} 2 | {{ template "global/header.tmpl" .}} 3 | 4 | icon 5 |

{{ .appname }}

6 |

{{ .appdesc }}

7 | 8 | {{ template "global/footer.tmpl" .}} 9 | {{ end }} 10 | 11 | -------------------------------------------------------------------------------- /routes/web/templates/home/login.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "home/login.tmpl"}} 2 | {{ template "global/header.tmpl" .}} 3 | 4 |
5 | 6 |
7 | 8 |
9 |

{{ tr "Connection" }}

10 |
11 | 12 |
13 | 14 |
15 | 16 | 34 | 35 |
36 | 37 |
38 | 39 | {{ template "global/footer.tmpl" .}} 40 | {{ end }} 41 | 42 | -------------------------------------------------------------------------------- /routes/web/templates/user/create.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "user/create.tmpl"}} 2 | {{ template "global/header.tmpl" .}} 3 | 4 |
5 | 6 | 7 |
8 | 9 |
10 | 11 |
{{ tr "Mandatory" }}, {{ tr "Distinct" }}, {{ tr "lower case ASCII characters" }}, {{ tr "min 2" }}, {{ tr "max 16" }}
12 | {{ with .u.Errors.Name }} 13 |
{{ . }}
14 | {{ end }} 15 |
16 |
17 | 18 | {{ if .u.Errors }} 19 | 24 | {{ end }} 25 |
26 | 27 | 28 | {{ template "global/footer.tmpl" .}} 29 | {{ end }} 30 | -------------------------------------------------------------------------------- /routes/web/templates/user/list.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "user/list.tmpl"}} 2 | {{ template "global/header.tmpl" .}} 3 | 4 | {{ tr "Add" }} 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {{ range .userdata }} 19 | 20 | 21 | 34 | 35 | 36 | 40 | 41 | 42 | {{ end }} 43 | 44 |
{{ tr "Username" }}{{ tr "Groups" }}{{ tr "Name" }}{{ tr "Mail" }}{{ tr "Enabled" }}
{{ .Name }} {{ (index $.hashgroups .PrimaryGroup) }} 22 | {{ if eq (index $.hashgroups .PrimaryGroup) $.groupsinfo.Admins }} {{end}} 23 | {{ if eq (index $.hashgroups .PrimaryGroup) $.groupsinfo.Users }} {{end}} 24 | {{ if eq (index $.hashgroups .PrimaryGroup) $.groupsinfo.OTP }} {{end}} 25 | 26 |
27 | {{range .OtherGroups}} 28 | {{ (index $.hashgroups .) }} 29 | {{ if eq (index $.hashgroups .) $.groupsinfo.Admins }} {{end}} 30 | {{ if eq (index $.hashgroups .) $.groupsinfo.Users }} {{end}} 31 | {{ if eq (index $.hashgroups .) $.groupsinfo.OTP }} {{end}} 32 | {{end}} 33 |
{{ .GivenName }} {{.SN}}{{ .Mail }} 37 | {{ if .Disabled }} {{ tr "No" }} {{else}} {{ tr "Yes" }} {{end}} 38 | {{ with .OTPSecret }} {{end}} 39 | {{ tr "Edit" }}
45 |
46 | 47 |

48 |

56 |

57 | 58 | {{ template "global/footer.tmpl" .}} 59 | {{ end }} 60 | -------------------------------------------------------------------------------- /routes/web/templates/user/profile.tmpl: -------------------------------------------------------------------------------- 1 | {{ define "user/profile.tmpl"}} 2 | {{ template "global/header.tmpl" .}} 3 | 4 | 15 | 16 |