├── .env.sample ├── .gitignore ├── .goreleaser.yaml ├── AUTHORS.rst ├── ChangeLog.md ├── LICENSE ├── NOTICE ├── README.md ├── cmd ├── root.go └── serve │ └── serve.go ├── container ├── Dockerfile.client ├── Dockerfile.ipa ├── init-data ├── init-ipa-server-install-options ├── ipa-client-enroll ├── ipa-client-enroll.service ├── ipa-precreate-hosts ├── ipa-precreate-hosts.service ├── populate-data-volume ├── populate-data-volume.service └── volume-data-list ├── docker-compose.yml ├── docs ├── mokey-logo.png ├── mokey-screenshot-home.png └── mokey-screenshot-login.png ├── examples └── mokey-oidc │ ├── .gitignore │ ├── main.go │ └── mokey-oidc.conf.sample ├── go.mod ├── go.sum ├── main.go ├── mokey.toml.sample ├── scripts └── nfpm │ ├── mokey.env │ ├── mokey.service │ ├── mokey.toml.default │ └── postinstall.sh └── server ├── account.go ├── auth.go ├── captcha.go ├── const.go ├── csrf.go ├── email.go ├── hydra.go ├── metrics.go ├── middleware.go ├── otp.go ├── password.go ├── password_test.go ├── qrcode.go ├── router.go ├── security.go ├── server.go ├── session.go ├── sshpubkey.go ├── template.go ├── templates ├── 401.html ├── 403-partial.html ├── 403.html ├── 404-partial.html ├── 404.html ├── 500-partial.html ├── 500.html ├── account-verify-forgot-success.html ├── account-verify-forgot.html ├── account.html ├── email │ ├── account-updated.html │ ├── account-updated.txt │ ├── account-verify.html │ ├── account-verify.txt │ ├── password-reset.html │ ├── password-reset.txt │ ├── welcome.html │ └── welcome.txt ├── footer.html ├── header.html ├── index.html ├── login-form.html ├── login-password-expired.html ├── login.html ├── otptoken-list.html ├── otptoken-new.html ├── otptoken-scan.html ├── partials │ └── otp.html ├── password-forgot-success.html ├── password-forgot.html ├── password-reset-success.html ├── password-reset.html ├── password.html ├── security.html ├── signup-success.html ├── signup.html ├── sshkey-list.html ├── sshkey-new.html ├── static │ ├── css │ │ ├── bootstrap.min.css │ │ ├── bootstrap.min.css.map │ │ ├── fontawesome.all.min.css │ │ ├── fonts.css │ │ ├── style.css │ │ └── sweetalert2.min.css │ ├── images │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── scan-qr-code.png │ │ └── user-circle.png │ ├── js │ │ ├── bootstrap.bundle.min.js │ │ ├── bootstrap.bundle.min.js.map │ │ ├── htmx.min.js │ │ ├── hyperscript.min.js │ │ ├── site.js │ │ └── sweetalert2.min.js │ ├── manifest.json │ └── webfonts │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff2 │ │ ├── fa-regular-400.ttf │ │ ├── fa-regular-400.woff2 │ │ ├── fa-solid-900.ttf │ │ ├── fa-solid-900.woff2 │ │ ├── fa-v4compatibility.ttf │ │ ├── fa-v4compatibility.woff2 │ │ ├── roboto-v29-latin-300.woff │ │ ├── roboto-v29-latin-300.woff2 │ │ ├── roboto-v29-latin-300italic.woff │ │ ├── roboto-v29-latin-300italic.woff2 │ │ ├── roboto-v29-latin-500.woff │ │ ├── roboto-v29-latin-500.woff2 │ │ ├── roboto-v29-latin-500italic.woff │ │ ├── roboto-v29-latin-500italic.woff2 │ │ ├── roboto-v29-latin-700.woff │ │ ├── roboto-v29-latin-700.woff2 │ │ ├── roboto-v29-latin-700italic.woff │ │ ├── roboto-v29-latin-700italic.woff2 │ │ ├── roboto-v29-latin-italic.woff │ │ ├── roboto-v29-latin-italic.woff2 │ │ ├── roboto-v29-latin-regular.woff │ │ └── roboto-v29-latin-regular.woff2 ├── verify-account.html └── verify-success.html ├── token.go ├── token_test.go ├── usernames.go └── usernames_test.go /.env.sample: -------------------------------------------------------------------------------- 1 | IPA_ADMIN_PASS="changeme" 2 | IPA_DS_PASS="this is insecure" 3 | DEV_SSH_KEY="ssh-rsa AAAAxxxxxxxx" 4 | GO_VERSION=1.17.7 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | mokey.yaml 2 | mokey.toml 3 | mokey.db 4 | mokey 5 | .env 6 | dist/ 7 | key.gpg 8 | mokey-*.tar.gz 9 | *.swp 10 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # Mokey goreleaser configs 2 | # See here: https://goreleaser.com 3 | version: 2 4 | before: 5 | hooks: 6 | - go mod tidy 7 | builds: 8 | - env: 9 | - CGO_ENABLED=1 10 | goarch: 11 | - amd64 12 | goos: 13 | - linux 14 | ldflags: 15 | - -s -w -X github.com/ubccr/mokey/server.Version={{.Version}} 16 | - -extldflags=-static 17 | tags: 18 | - sqlite_omit_load_extension 19 | - osusergo 20 | - netgo 21 | archives: 22 | - id: release 23 | wrap_in_directory: true 24 | name_template: >- 25 | {{- .ProjectName }}- 26 | {{- .Version }}- 27 | {{- .Os }}- 28 | {{- if eq .Arch "amd64" }}x86_64 29 | {{- else if eq .Arch "arm64" }}aarch64 30 | {{- else }}{{ .Arch }}{{ end }} 31 | files: 32 | - LICENSE 33 | - NOTICE 34 | - README.md 35 | - ChangeLog.md 36 | - mokey.toml.sample 37 | nfpms: 38 | - vendor: University at Buffalo 39 | homepage: https://github.com/ubccr/mokey 40 | maintainer: Andrew E. Bruno 41 | license: MIT 42 | description: |- 43 | FreeIPA self-service account management tool 44 | formats: 45 | - deb 46 | - rpm 47 | overrides: 48 | deb: 49 | file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}" 50 | scripts: 51 | postinstall: ./scripts/nfpm/postinstall.sh 52 | rpm: 53 | file_name_template: >- 54 | {{- .ProjectName }}- 55 | {{- .Version }}- 56 | {{- if eq .Arch "amd64" }}x86_64 57 | {{- else if eq .Arch "arm64" }}aarch64 58 | {{- else }}{{ .Arch }}{{ end }} 59 | scripts: 60 | postinstall: ./scripts/nfpm/postinstall.sh 61 | rpm: 62 | signature: 63 | key_file: key.gpg 64 | deb: 65 | signature: 66 | key_file: key.gpg 67 | contents: 68 | - src: ./scripts/nfpm/mokey.toml.default 69 | dst: /etc/mokey/mokey.toml 70 | type: "config|noreplace" 71 | - src: ./scripts/nfpm/mokey.env 72 | dst: /etc/default/mokey 73 | type: "config|noreplace" 74 | - src: ./scripts/nfpm/mokey.service 75 | dst: /usr/lib/systemd/system/mokey.service 76 | checksum: 77 | name_template: 'checksums.txt' 78 | snapshot: 79 | version_template: "{{ incpatch .Version }}-SNAPSHOT-{{.ShortCommit}}" 80 | changelog: 81 | sort: desc 82 | groups: 83 | - title: Features 84 | regexp: "^.*feat[(\\w)]*:+.*$" 85 | order: 0 86 | - title: 'Bug fixes' 87 | regexp: "^.*fix[(\\w)]*:+.*$" 88 | order: 1 89 | - title: Other 90 | order: 999 91 | filters: 92 | exclude: 93 | - '^docs:' 94 | - 'typo' 95 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======================================================================== 2 | mokey - AUTHORS 3 | ======================================================================== 4 | 5 | mokey was originally written and currently maintained by: 6 | 7 | - Andrew E. Bruno 8 | -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | # Mokey ChangeLog 2 | 3 | ## [v0.6.5] - 2024-10-28 4 | 5 | - Update fiber, htmx (v2.0.3), hyperscript (v0.9.13) 6 | - Add config option to hide invalid username error on login [#92](https://github.com/ubccr/mokey/issues/92) 7 | 8 | ## [v0.6.4] - 2024-05-28 9 | 10 | - Update fiber 11 | - Add option to require admin verification [#121](https://github.com/ubccr/mokey/issues/121) 12 | - Fix OTP enabled bug [#125](https://github.com/ubccr/mokey/issues/125) 13 | 14 | ## [v0.6.3] - 2023-02-09 15 | 16 | - Update fiber 17 | - Update hydra go client 18 | - Trim spaces in names when creating/updating account 19 | - Add better error messages for name length 20 | - Add ability to better control log level 21 | 22 | ## [v0.6.2] - 2023-01-26 23 | 24 | - Fix sshpubkey update bug 25 | 26 | ## [v0.6.1] - 2023-01-26 27 | 28 | - Fix account settings update bug 29 | - Add hydra login prometheus counters 30 | 31 | ## [v0.6.0] - 2023-01-25 32 | 33 | - Major re-write. New login flow and template layout 34 | - Upgrade to bootstrap 5 35 | - Remove database dependency 36 | - Switch to using Fiber web framework and htmx frontend 37 | - New email text/html templates 38 | - Add terms of service url to sign up page [#97](https://github.com/ubccr/mokey/issues/97) 39 | - Add better messaging for disabled user at login [#22](https://github.com/ubccr/mokey/issues/22) 40 | - Notification email sent anytime account updated [#82](https://github.com/ubccr/mokey/issues/82) 41 | - Allow configuring default hash algorithm for OTP [#99](https://github.com/ubccr/mokey/issues/99) 42 | - Add user block list [#83](https://github.com/ubccr/mokey/issues/83) 43 | - Make server timeouts configurable [#109](https://github.com/ubccr/mokey/issues/109) 44 | 45 | ## [v0.5.6] - 2021-05-18 46 | 47 | - Add config option to replace unexpired password tokens 48 | - Add email flag to resetpw command 49 | - Relax CSP settings to allow inline images and js 50 | - Add change expired password login flow 51 | 52 | ## [v0.5.5] - 2021-03-25 53 | 54 | - Add security related HTTP headers #55 55 | - Upgrade to latest hydra sdk. Tested against hydra v1.9.2 56 | - Verify nsaccountlock before sending password reset email @cmd-ntrf 57 | - Add option to require admin verification to enable new account @cmd-ntrf 58 | - Restrict username to lowercase and not only number when signing up @cmd-ntrf 59 | - Add option to always skip consent in hydra login flow @isard-vdi 60 | 61 | ## [v0.5.4] - 2020-07-14 62 | 63 | - Fix bug with missing set-cookie header issue #53 64 | 65 | ## [v0.5.3] - 2019-10-29 66 | 67 | - Update Login/Conset flow for hydra v1.0.3+oryOS.10 68 | - Add support for SMTP AUTH (@cdwertmann) 69 | - Implement fully encrypted SMTP connection (@g5pw) 70 | - Fix bug if session keys change or session gets corrupted 71 | - Upgrade to echo v4 72 | 73 | ## [v0.5.2] - 2018-09-12 74 | 75 | - Add option to disable user signup 76 | - Add new command for re-sending verify emails 77 | 78 | ## [v0.5.1] - 2018-09-12 79 | 80 | - Major code refactor to use echo framework 81 | - Add user signup/registration (Fixes #8) 82 | - Add support for new Login/Conset flow in hydra 1.0.0 83 | - Add ApiKey support for hydra consent 84 | - Add CAPTCHA support 85 | - Add Globus support to user account sign up 86 | - Simplify login to be more like FreeIPA (password+otp) 87 | - Remove security questions 88 | - Remove dependecy on krb5-libs (now using pure go kerberos library) 89 | - Update build to use vgo 90 | 91 | ## [v0.0.6] - 2018-01-09 92 | 93 | - Add new OAuth/OpenID Connect consent endpoint for Hydra 94 | - Add support for api key access to consent endpoint 95 | - Add user status command 96 | - Add support for FreeIPA 4.5 97 | - Fix optional security question on password reset for fresh accounts (PR #11) 98 | 99 | ## [v0.0.5] - 2017-08-01 100 | 101 | - Add support for managing SSH Public Keys 102 | - Add support for managing OTP Tokens 103 | - Add support for enabling Two-Factor Authentication 104 | - Refresh UI 105 | 106 | ## [v0.0.4] - 2015-09-03 107 | 108 | - Min password length configurable option 109 | - Add HMAC signed tokens 110 | 111 | ## [v0.0.3] - 2015-09-02 112 | 113 | - Rate limiting configurable option 114 | - Re-locate static template directory 115 | - Add check for empty user name in forgot password 116 | 117 | ## [v0.0.2] - 2015-08-29 118 | 119 | - Add rpm spec 120 | - Set ipahost from /etc/ipa/default.conf 121 | 122 | ## [v0.0.1] - 2015-08-28 123 | 124 | - Initial release 125 | 126 | [v0.0.1]: https://github.com/ubccr/mokey/releases/tag/v0.0.1 127 | [v0.0.2]: https://github.com/ubccr/mokey/releases/tag/v0.0.2 128 | [v0.0.3]: https://github.com/ubccr/mokey/releases/tag/v0.0.3 129 | [v0.0.4]: https://github.com/ubccr/mokey/releases/tag/v0.0.4 130 | [v0.0.5]: https://github.com/ubccr/mokey/releases/tag/v0.0.5 131 | [v0.0.6]: https://github.com/ubccr/mokey/releases/tag/v0.0.6 132 | [v0.5.1]: https://github.com/ubccr/mokey/releases/tag/v0.5.1 133 | [v0.5.2]: https://github.com/ubccr/mokey/releases/tag/v0.5.2 134 | [v0.5.3]: https://github.com/ubccr/mokey/releases/tag/v0.5.3 135 | [v0.5.4]: https://github.com/ubccr/mokey/releases/tag/v0.5.4 136 | [v0.5.5]: https://github.com/ubccr/mokey/releases/tag/v0.5.5 137 | [v0.5.6]: https://github.com/ubccr/mokey/releases/tag/v0.5.6 138 | [v0.6.0]: https://github.com/ubccr/mokey/releases/tag/v0.6.0 139 | [v0.6.1]: https://github.com/ubccr/mokey/releases/tag/v0.6.1 140 | [v0.6.2]: https://github.com/ubccr/mokey/releases/tag/v0.6.2 141 | [v0.6.3]: https://github.com/ubccr/mokey/releases/tag/v0.6.3 142 | [v0.6.4]: https://github.com/ubccr/mokey/releases/tag/v0.6.4 143 | [v0.6.5]: https://github.com/ubccr/mokey/releases/tag/v0.6.6 144 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Andrew E. Bruno 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of The University at Buffalo nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | mokey includes software developed by: 2 | 3 | - Bootstrap. Copyright 2011-2018 the Bootstrap Authors and Twitter, Inc. Code 4 | released under the MIT License. 5 | 6 | - Font Awesome by Dave Gandy (http://fontawesome.io) CSS released under MIT 7 | License. Webfonts released under SIL OFL 1.1 8 | 9 | - htmx (https://htmx.org/) Copyright 2020, Big Sky Software released under 10 | BSD 2-Clause License 11 | 12 | - sweetalert2 (https://sweetalert2.github.io/) Copyright 2014 Tristan Edwards & 13 | Limon Monte released under the MIT License. 14 | 15 | - Email templates adopted from postmark-templates (https://github.com/ActiveCampaign/postmark-templates) 16 | Copyright 2015 Wildbit released under the MIT License (MIT) 17 | 18 | - docker-compose development environment adopted from the webauthinfra project. 19 | (https://github.com/adelton/webauthinfra). Web application authentication developer setup 20 | Copyright 2016--2018 Jan Pazdziora Licensed under the Apache License, Version 2.0 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FreeIPA self-service account management tool 2 | 3 | ## What is mokey? 4 | 5 | mokey is web application that provides self-service user account management 6 | tools for [FreeIPA](https://www.freeipa.org). The motivation for this project was 7 | to implement the self-service account creation and password reset functionality 8 | missing in FreeIPA. This feature is not provided by default in FreeIPA, see 9 | [here](https://www.freeipa.org/page/Self-Service_Password_Reset) for more info 10 | and the rationale behind this decision. mokey is not a FreeIPA plugin but a 11 | complete standalone application that uses the FreeIPA JSON API. mokey requires 12 | no changes to the underlying LDAP schema and uses a MariaDB database to store 13 | access tokens. The user experience and web interface can be customized to fit 14 | the requirements of an organization's look and feel. mokey is written in Go and 15 | released under a modified BSD license. 16 | 17 | ## Project status 18 | 19 | mokey should be considered alpha software and used at your own risk. There are 20 | inherent security risks in providing features like self-service password resets 21 | and can make your systems vulnerable to abuse. 22 | 23 | ## Features 24 | 25 | - Account Signup 26 | - Forgot/Change Password 27 | - Add/Remove SSH Public Keys 28 | - Add/Remove TOTP Tokens 29 | - Enable/Disable Two-Factor Authentication 30 | - Hydra Consent/Login Endpoint for OAuth/OpenID Connect 31 | - Easy to install and configure (requires no FreeIPA/LDAP schema changes) 32 | 33 | ## Requirements 34 | 35 | - FreeIPA v4.6.8 or greater 36 | - Linux x86_64 37 | - Redis (optional) 38 | - Hydra v1.0.0 (optional) 39 | 40 | ## Install 41 | 42 | Note: mokey needs to be installed on a machine already enrolled in FreeIPA. 43 | It's also recommended to have the ipa-admintools package installed. Enrolling a 44 | host in FreeIPA is outside the scope of this document. 45 | 46 | To install mokey download a copy of the pre-compiled binary [here](https://github.com/ubccr/mokey/releases). 47 | 48 | tar.gz archive: 49 | 50 | ``` 51 | $ tar xvzf mokey-VERSION-linux-x86_64.tar.gz 52 | ``` 53 | 54 | deb, rpm packages: 55 | 56 | ``` 57 | $ sudo dpkg -i mokey_VERSION_amd64.deb 58 | 59 | $ sudo rpm -ivh mokey-VERSION-amd64.rpm 60 | ``` 61 | 62 | ## Setup and configuration 63 | 64 | Create a user account and role in FreeIPA with the "Modify users and Reset 65 | passwords" privilege. This user account will be used by the mokey application 66 | to reset users passwords. The "Modify Users" permission also needs to have the 67 | "ipauserauthtype" enabled. Run the following commands (requires ipa-admintools 68 | to be installed): 69 | 70 | ``` 71 | $ mkdir /etc/mokey/private 72 | $ kinit adminuser 73 | $ ipa role-add 'Mokey User Manager' --desc='Mokey User management' 74 | $ ipa role-add-privilege 'Mokey User Manager' --privilege='User Administrators' 75 | $ ipa user-add mokeyapp --first Mokey --last App 76 | $ ipa role-add-member 'Mokey User Manager' --users=mokeyapp 77 | $ ipa permission-mod 'System: Modify Users' --includedattrs=ipauserauthtype 78 | $ ipa-getkeytab -s [your.ipa-master.server] -p mokeyapp -k /etc/mokey/private/mokeyapp.keytab 79 | $ chmod 640 /etc/mokey/private/mokeyapp.keytab 80 | $ chgrp mokey /etc/mokey/private/mokeyapp.keytab 81 | ``` 82 | 83 | Edit mokey configuration file and set path to keytab file. The values for 84 | `token_secret` and `csrf_secret` will be automatically generated for you if 85 | left blank. Set these secret values if you'd like sessions to persist after a restart. 86 | For other site specific config options [see here](https://github.com/ubccr/mokey/blob/main/mokey.toml.sample): 87 | 88 | ``` 89 | $ vim /etc/mokey/mokey.toml 90 | # Path to keytab file 91 | keytab = "/etc/mokey/private/mokeyapp.keytab" 92 | 93 | # Secret key for branca tokens. Must be 32 bytes. To generate run: 94 | # openssl rand -hex 32 95 | token_secret = "" 96 | 97 | # CSRF token secret key. Should be a random string 98 | csrf_secret = "" 99 | ``` 100 | 101 | It's highly recommended to run mokey using HTTPS. You'll need an SSL 102 | cert/private_key either using FreeIPA's PKI, self-signed, or from a commercial 103 | certificate authority. Creating SSL certs is outside the scope of this 104 | document. You can also run mokey behind haproxy or Apache/Nginx. 105 | 106 | Start mokey service: 107 | 108 | ``` 109 | $ systemctl restart mokey 110 | $ systemctl enable mokey 111 | ``` 112 | 113 | ## SSH Public Key Management 114 | 115 | mokey allows users to add/remove ssh public keys. Servers that are enrolled in 116 | FreeIPA can be configured to have sshd lookup users public keys in LDAP by 117 | adding the following lines in /etc/ssh/sshd_config and restarting sshd: 118 | 119 | AuthorizedKeysCommand /usr/bin/sss_ssh_authorizedkeys 120 | AuthorizedKeysCommandUser nobody 121 | 122 | ## Hydra Consent and Login Endpoint for OAuth/OpenID Connect 123 | 124 | mokey implements the login/consent flow for handling challenge requests from 125 | Hydra. This serves as the bridge between Hydra and FreeIPA identity provider. 126 | For more information on Hydra and the login/consent flow see [here](https://www.ory.sh/docs/hydra/oauth2). 127 | 128 | To configure the Hydra login/consent flow set the following variables in 129 | `/etc/mokey/mokey.toml`: 130 | 131 | ``` 132 | [hydra] 133 | admin_url = "http://127.0.0.1:4445" 134 | login_timeout = 86400 135 | fake_tls_termination = true 136 | ``` 137 | 138 | Any OAuth clients configured in Hydra will be authenticated via mokey using 139 | FreeIPA as the identity provider. For an example OAuth 2.0/OIDC client 140 | application see [here](examples/mokey-oidc/main.go). 141 | 142 | ## Building from source 143 | 144 | First, you will need Go v1.21 or greater. Clone the repository: 145 | 146 | ``` 147 | $ git clone https://github.com/ubccr/mokey 148 | $ cd mokey 149 | $ go build . 150 | ``` 151 | 152 | ## License 153 | 154 | mokey is released under a BSD style license. See the LICENSE file. 155 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | golog "log" 7 | "os" 8 | "strings" 9 | 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/viper" 13 | "github.com/ubccr/mokey/server" 14 | ) 15 | 16 | var ( 17 | cfgFile string 18 | cfgFileUsed string 19 | logLevel string 20 | 21 | Root = &cobra.Command{ 22 | Use: "mokey", 23 | Version: server.Version, 24 | Short: "FreeIPA self-service account management tool", 25 | Long: ``, 26 | } 27 | ) 28 | 29 | func Execute() { 30 | if err := Root.Execute(); err != nil { 31 | logrus.Fatal(err) 32 | } 33 | } 34 | 35 | func init() { 36 | cobra.OnInitialize(initConfig) 37 | 38 | Root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file") 39 | Root.PersistentFlags().StringVar(&logLevel, "loglevel", "info", "Set log level") 40 | 41 | Root.PersistentPreRunE = func(command *cobra.Command, args []string) error { 42 | return SetupLogging() 43 | } 44 | } 45 | 46 | func SetupLogging() error { 47 | switch logLevel { 48 | case "trace": 49 | logrus.SetLevel(logrus.TraceLevel) 50 | case "debug": 51 | logrus.SetLevel(logrus.DebugLevel) 52 | case "info": 53 | logrus.SetLevel(logrus.InfoLevel) 54 | case "warn": 55 | logrus.SetLevel(logrus.WarnLevel) 56 | case "error": 57 | logrus.SetLevel(logrus.ErrorLevel) 58 | default: 59 | return fmt.Errorf("Unknown log level: %s", logLevel) 60 | } 61 | 62 | golog.SetOutput(ioutil.Discard) 63 | 64 | if cfgFileUsed != "" { 65 | logrus.Infof("Using config file: %s", cfgFileUsed) 66 | } 67 | 68 | Root.SilenceUsage = true 69 | Root.SilenceErrors = true 70 | 71 | return nil 72 | } 73 | 74 | func initConfig() { 75 | viper.SetConfigType("toml") 76 | if cfgFile != "" { 77 | viper.SetConfigFile(cfgFile) 78 | } else { 79 | cwd, err := os.Getwd() 80 | if err != nil { 81 | logrus.Fatal(err) 82 | } 83 | 84 | viper.AddConfigPath("/etc/mokey/") 85 | viper.AddConfigPath(cwd) 86 | viper.SetConfigName("mokey.toml") 87 | } 88 | 89 | server.SetDefaults() 90 | viper.AutomaticEnv() 91 | viper.SetEnvPrefix("mokey") 92 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 93 | 94 | if err := viper.ReadInConfig(); err != nil { 95 | if _, ok := err.(viper.ConfigFileNotFoundError); !ok { 96 | logrus.Fatalf("Failed parsing config file %s: %s", viper.ConfigFileUsed(), err) 97 | } 98 | } else { 99 | cfgFileUsed = viper.ConfigFileUsed() 100 | } 101 | 102 | if !viper.IsSet("email.token_secret") { 103 | secret, err := server.GenerateSecret(32) 104 | if err != nil { 105 | logrus.Fatal(err) 106 | } 107 | 108 | viper.Set("email.token_secret", secret) 109 | } 110 | 111 | if !viper.IsSet("server.csrf_secret") { 112 | secret, err := server.GenerateSecretString(16) 113 | if err != nil { 114 | logrus.Fatal(err) 115 | } 116 | 117 | viper.Set("server.csrf_secret", secret) 118 | } 119 | 120 | if !viper.IsSet("site.keytab") { 121 | logrus.Fatalf("Please provide path to keytab file") 122 | } 123 | 124 | if viper.IsSet("accounts.otp_hash_algorithm") { 125 | algo := viper.GetString("accounts.otp_hash_algorithm") 126 | validAlgo := false 127 | for _, a := range []string{"sha1", "sha256", "sha512"} { 128 | if algo == a { 129 | validAlgo = true 130 | break 131 | } 132 | } 133 | 134 | if !validAlgo { 135 | logrus.Fatalf("Invalid otp hash algorithm: %s", algo) 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /cmd/serve/serve.go: -------------------------------------------------------------------------------- 1 | package serve 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/signal" 7 | "time" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | "github.com/ubccr/mokey/cmd" 13 | "github.com/ubccr/mokey/server" 14 | ) 15 | 16 | var ( 17 | serveCmd = &cobra.Command{ 18 | Use: "serve", 19 | Short: "Run server", 20 | Long: `Run server`, 21 | RunE: func(command *cobra.Command, args []string) error { 22 | return serve() 23 | }, 24 | } 25 | ) 26 | 27 | func init() { 28 | serveCmd.Flags().String("keytab", "", "path to keytab file") 29 | viper.BindPFlag("site.keytab", serveCmd.Flags().Lookup("keytab")) 30 | 31 | serveCmd.Flags().String("listen", "0.0.0.0:8866", "address to listen on") 32 | viper.BindPFlag("server.listen", serveCmd.Flags().Lookup("listen")) 33 | serveCmd.Flags().String("cert", "", "path to ssl cert") 34 | viper.BindPFlag("server.ssl_cert", serveCmd.Flags().Lookup("cert")) 35 | serveCmd.Flags().String("key", "", "path to ssl key") 36 | viper.BindPFlag("server.ssl_key", serveCmd.Flags().Lookup("key")) 37 | serveCmd.Flags().String("dbpath", "", "path to mokey database") 38 | viper.BindPFlag("storage.sqlite3.dbpath", serveCmd.Flags().Lookup("dbpath")) 39 | 40 | serveCmd.Flags().String("smtp-host", "localhost", "smtp host") 41 | viper.BindPFlag("email.smtp_host", serveCmd.Flags().Lookup("smtp-host")) 42 | 43 | serveCmd.Flags().Int("smtp-port", 25, "smtp port") 44 | viper.BindPFlag("email.smtp_port", serveCmd.Flags().Lookup("smtp-port")) 45 | 46 | serveCmd.Flags().String("email-from", "", "from email address") 47 | viper.BindPFlag("email.from", serveCmd.Flags().Lookup("email-from")) 48 | 49 | cmd.Root.AddCommand(serveCmd) 50 | } 51 | 52 | func serve() error { 53 | srv, err := server.NewServer(viper.GetString("server.listen")) 54 | if err != nil { 55 | return err 56 | } 57 | 58 | srv.KeyFile = viper.GetString("server.ssl_key") 59 | srv.CertFile = viper.GetString("server.ssl_cert") 60 | 61 | go func() { 62 | quit := make(chan os.Signal, 1) 63 | signal.Notify(quit, os.Interrupt) 64 | <-quit 65 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 | defer cancel() 67 | 68 | logrus.Debug("Shutting down server") 69 | srv.Shutdown(ctx) 70 | }() 71 | 72 | return srv.Serve() 73 | } 74 | -------------------------------------------------------------------------------- /container/Dockerfile.client: -------------------------------------------------------------------------------- 1 | FROM rockylinux:9 2 | RUN dnf upgrade -y 3 | RUN dnf install systemd ipa-client openssh-server openssh-clients sudo vim wget perl 'perl(Data::Dumper)' 'perl(Time::HiRes)' -y 4 | COPY init-data ipa-client-enroll populate-data-volume /usr/sbin/ 5 | RUN sed -i 's/^#AddressFamily any/AddressFamily inet/' /etc/ssh/sshd_config 6 | COPY ipa-client-enroll.service populate-data-volume.service /usr/lib/systemd/system/ 7 | RUN ln -s /usr/lib/systemd/system/ipa-client-enroll.service /usr/lib/systemd/system/default.target.wants/ 8 | RUN ln -s /usr/lib/systemd/system/sshd.service /usr/lib/systemd/system/default.target.wants/ 9 | RUN ln -s /usr/lib/systemd/system/populate-data-volume.service /usr/lib/systemd/system/default.target.wants/ 10 | COPY volume-data-list /etc/ 11 | 12 | ARG DEV_SSH_KEY 13 | ARG GO_VERSION 14 | ARG USER 15 | ARG USER_ID 16 | 17 | RUN useradd -m --uid=${USER_ID} ${USER} \ 18 | && mkdir /home/${USER}/.ssh \ 19 | && echo "$DEV_SSH_KEY" > /home/${USER}/.ssh/authorized_keys \ 20 | && chmod 600 /home/${USER}/.ssh/authorized_keys \ 21 | && echo 'export PATH=$PATH:/usr/local/go/bin' >> /home/${USER}/.bashrc \ 22 | && chown -R ${USER}:${USER} /home/${USER} 23 | 24 | RUN install -o ${USER_ID} -g ${USER_ID} -m 0755 -d /app 25 | RUN wget https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz 26 | RUN tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz 27 | 28 | RUN echo "${USER} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-dev-user \ 29 | && chmod 440 /etc/sudoers.d/90-dev-user 30 | 31 | ENV container docker 32 | VOLUME [ "/tmp", "/run", "/data" ] 33 | ENTRYPOINT /usr/sbin/init-data 34 | -------------------------------------------------------------------------------- /container/Dockerfile.ipa: -------------------------------------------------------------------------------- 1 | FROM freeipa/freeipa-server:rocky-9 2 | ARG IPA_ADMIN_PASS 3 | ARG IPA_DS_PASS 4 | COPY init-ipa-server-install-options ipa-precreate-hosts /usr/sbin/ 5 | COPY ipa-precreate-hosts.service /usr/lib/systemd/system/ 6 | RUN mkdir /usr/lib/systemd/system/container-ipa.target.wants \ 7 | && ln -s /usr/lib/systemd/system/ipa-precreate-hosts.service /usr/lib/systemd/system/container-ipa.target.wants/ipa-precreate-hosts.service 8 | RUN ln -s /data /var/www/html/pub 9 | 10 | ENV IPA_ADMIN_PASS $IPA_ADMIN_PASS 11 | ENV IPA_DS_PASS $IPA_DS_PASS 12 | ENTRYPOINT [ "/usr/sbin/init-ipa-server-install-options" ] 13 | -------------------------------------------------------------------------------- /container/init-data: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2016--2017 Jan Pazdziora 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). 6 | 7 | # Populates data in the image from the /data volume. Also 8 | # waits for the IPA server to be ready. 9 | 10 | set -e 11 | 12 | UNAME=$( uname -n ) 13 | if [ "$HOSTNAME" != "$UNAME" -a "$HOSTNAME" == "${UNAME%%.*}" ] ; then 14 | HOSTNAME="$UNAME" 15 | fi 16 | 17 | for i in /run/* /tmp/* ; do 18 | if [ -e "$i" -a "$i" != '/run/secrets' ] ; then 19 | rm -rf "$i" 20 | fi 21 | done 22 | 23 | if [ -f /data/volume-version ] ; then 24 | sed 's%^/%%' /etc/volume-data-list | while read f ; do [ -e /data/$f -o -L /data/$f ] && echo $f ; done | ( cd /data && xargs cp --parents -rp -t / ) 25 | fi 26 | 27 | DOMAIN=mokey.local 28 | IPA=ipa.$DOMAIN 29 | 30 | IPA_IP_FROM_DOCKER=$( host $IPA | awk '/has address (.+)/ { print $4; exit}' ) 31 | 32 | i=0 33 | while ! curl -fs http://$IPA/ipa/config/ca.crt &> /dev/null ; do 34 | if [ "$(( i % 20 ))" -eq 0 ] ; then 35 | echo "Waiting for FreeIPA server (HTTP Server) ..." 36 | fi 37 | i=$(( i + 1 )) 38 | sleep 1 39 | done 40 | i=0 41 | while ! dig NS mokey.local &> /dev/null ; do 42 | if [ "$(( i % 20 ))" -eq 0 ] ; then 43 | echo "Waiting for FreeIPA server (DNS) ..." 44 | fi 45 | i=$(( i + 1 )) 46 | sleep 1 47 | done 48 | 49 | echo "nameserver $IPA_IP_FROM_DOCKER" > /etc/resolv.conf 50 | echo "Pointing resolv.conf at $HOSTNAME to $IPA_IP_FROM_DOCKER" 51 | 52 | i=0 53 | while true ; do 54 | IPA_IP_FROM_IPA=$( host $IPA | awk '/has address (.+)/ { print $4; exit}' ) 55 | if [ "$IPA_IP_FROM_DOCKER" == "$IPA_IP_FROM_IPA" ] ; then 56 | break 57 | fi 58 | if [ "$(( i % 20 ))" -eq 0 ] ; then 59 | echo "Waiting for FreeIPA server (its IP address in DNS) ..." 60 | fi 61 | i=$(( i + 1 )) 62 | sleep 1 63 | done 64 | 65 | echo "FreeIPA server is ready." 66 | 67 | while ! ( set -x ; curl -o /etc/pki/ca-trust/source/anchors/ipa-ca.crt -fs http://$IPA/ipa/config/ca.crt ) ; do 68 | sleep 1 69 | done 70 | ( 71 | set -x 72 | update-ca-trust 73 | ) 74 | 75 | ( 76 | trap '' SIGHUP 77 | rm -rf /run/docker-console 78 | mkdir -p /run/docker-console 79 | (sleep infinity) & 80 | ln -s /proc/$!/fd /run/docker-console/ 81 | ) 82 | 83 | exec /usr/sbin/init 84 | -------------------------------------------------------------------------------- /container/init-ipa-server-install-options: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2016--2017 Jan Pazdziora 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). 6 | 7 | # If the FreeIPA server does not contain ipa-server-install-options 8 | # and the server was not configured yet, produce some default 9 | # configuration for the developer setup. 10 | 11 | set -e 12 | 13 | cd / 14 | 15 | UNAME=$( uname -n ) 16 | if [ "$HOSTNAME" != "$UNAME" -a "$HOSTNAME" == "${UNAME%%.*}" ] ; then 17 | HOSTNAME="$UNAME" 18 | fi 19 | 20 | DATA=/data 21 | if ! [ -f /etc/ipa/ca.crt -a -f $DATA/ipa-server-install-options ] ; then 22 | echo "Configuring $HOSTNAME ..." 23 | echo $IPA_ADMIN_PASS > $DATA/admin-password 24 | echo $IPA_DS_PASS > $DATA/ds-master-password 25 | DOMAIN=${HOSTNAME#*.} 26 | REALM=${DOMAIN^^} 27 | cat > $DATA/ipa-server-install-options <> /run/docker-console/fd/1 2>> /run/docker-console/fd/2 14 | 15 | workaround_1708275 () { 16 | # Workaround 1708275 17 | if ! grep -q dyndns_refresh_interval /etc/sssd/sssd.conf ; then 18 | sed -i '/^\[domain/adyndns_refresh_interval = 999999' /etc/sssd/sssd.conf 19 | fi 20 | } 21 | 22 | if [ -f /etc/ipa/default.conf ] ; then 23 | echo "$HOSTNAME is already IPA-enrolled." 24 | workaround_1708275 25 | exit 26 | fi 27 | 28 | DOMAIN=mokey.local 29 | IPA=ipa.$DOMAIN 30 | 31 | i=0 32 | while ! curl -fs https://$IPA/pub/$HOSTNAME-otp &> /dev/null ; do 33 | if [ "$(( i % 20 ))" -eq 0 ] ; then 34 | echo "Waiting for my host record and OTP ..." 35 | fi 36 | i=$(( i + 1 )) 37 | sleep 1 38 | done 39 | 40 | ( 41 | set -x 42 | curl -o /tmp/otp-password -fs https://$IPA/pub/$HOSTNAME-otp 43 | ipa-client-install --server $IPA --domain $DOMAIN --password $( cat /tmp/otp-password ) --enable-dns-updates --no-ntp --no-nisdomain --no-ssh --no-sshd --no-sudo --no-dns-sshfp --unattended 44 | ) 45 | 46 | workaround_1708275 47 | 48 | if id apache &> /dev/null ; then 49 | HTTP_CERT=/etc/pki/tls/certs/localhost.crt 50 | rm -f $HTTP_CERT 51 | 52 | ( 53 | set -x 54 | 55 | kinit -k 56 | ipa-getkeytab -k /etc/http.keytab -s $IPA -p HTTP/$HOSTNAME 57 | chown apache /etc/http.keytab 58 | 59 | ipa-getcert request -k /etc/pki/tls/private/localhost.key -f $HTTP_CERT -N $HOSTNAME -K HTTP/$HOSTNAME -w 60 | chown apache $HTTP_CERT 61 | ) 62 | fi 63 | -------------------------------------------------------------------------------- /container/ipa-client-enroll.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=IPA-enroll the machine to the FreeIPA server 3 | 4 | [Service] 5 | Type=oneshot 6 | ExecStart=/usr/sbin/ipa-client-enroll 7 | RemainAfterExit=true 8 | -------------------------------------------------------------------------------- /container/ipa-precreate-hosts: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2016--2018 Jan Pazdziora 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). 6 | 7 | # Prepare HBAC rules for services webapp and idp, and OTPs for IPA clients. 8 | 9 | set -e 10 | 11 | exec >> /var/log/ipa-server-run.log 2>&1 12 | 13 | HOSTS="client.mokey.local" 14 | for i in $HOSTS ; do 15 | if ! [ -f /data/$i-otp ] ; then 16 | echo "Creating host record for $i" 17 | klist > /dev/null || kinit admin < /data/admin-password 18 | ( 19 | set -x 20 | ipa host-find $i > /dev/null && ipa host-del $i 21 | ipa host-add --random $i --force --raw | awk '/randompassword:/ { print $2 }' > /data/$i-otp.1 22 | ipa service-add --force HTTP/$i 23 | mv /data/$i-otp.1 /data/$i-otp 24 | ) 25 | fi 26 | done 27 | 28 | kdestroy -A 29 | -------------------------------------------------------------------------------- /container/ipa-precreate-hosts.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Create IPA client host records and OTPs, and HBAC rules 3 | After=ipa.service 4 | After=ipa-server-configure-first.service 5 | After=ipa-server-update-self-ip-address.service 6 | 7 | [Service] 8 | Type=oneshot 9 | ExecStart=/usr/sbin/ipa-precreate-hosts 10 | RemainAfterExit=true 11 | 12 | [Install] 13 | WantedBy=container-ipa.target 14 | -------------------------------------------------------------------------------- /container/populate-data-volume: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Copyright 2016 Jan Pazdziora 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"). 6 | 7 | # Upon the first run, populates the /data directory, to be used during 8 | # subsequent startups. 9 | 10 | set -e 11 | 12 | exec >> /run/docker-console/fd/1 2>> /run/docker-console/fd/2 13 | 14 | if [ -f /data/volume-version ] ; then 15 | echo "The data volume is already populated on $HOSTNAME." 16 | exit 17 | fi 18 | 19 | cat /etc/volume-data-list | while read f ; do [ -e $f ] && echo $f ; done | ( set -x ; xargs cp --parents -rp -t /data ) 20 | set -x 21 | echo 0.1 > /data/volume-version 22 | -------------------------------------------------------------------------------- /container/populate-data-volume.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Store the data in /data 3 | After=ipa-client-enroll.service ipsilon-server-configure.service ipsilon-client-configure.service ipsilon-server-wait-for-sp.service 4 | 5 | [Service] 6 | Type=oneshot 7 | ExecStart=/usr/sbin/populate-data-volume 8 | RemainAfterExit=true 9 | -------------------------------------------------------------------------------- /container/volume-data-list: -------------------------------------------------------------------------------- 1 | /var/log/ 2 | /var/lib/authconfig/last/ 3 | /var/lib/sss/ 4 | /var/lib/ipa-client/sysrestore/ 5 | /var/lib/systemd/catalog/ 6 | /var/lib/systemd/random-seed 7 | /var/lib/ipsilon/ 8 | /etc/authselect/ 9 | /etc/nsswitch.conf 10 | /etc/sysconfig/authconfig 11 | /etc/systemd/system/multi-user.target.wants/ 12 | /etc/pam.d/ 13 | /etc/openldap/ldap.conf 14 | /etc/sssd/sssd.conf 15 | /etc/http.keytab 16 | /etc/httpd/saml2/ 17 | /etc/ipa/ca.crt 18 | /etc/ipa/default.conf 19 | /etc/ipa/nssdb/ 20 | /etc/pki/ca-trust/ 21 | /etc/pki/nssdb/ 22 | /etc/pki/tls/certs/localhost.crt 23 | /etc/pki/tls/private/localhost.key 24 | /etc/krb5.keytab 25 | /etc/krb5.conf 26 | /etc/ipsilon/ 27 | /etc/httpd/conf.d/ipsilon-idp.conf 28 | /etc/ssh/ssh_host_ecdsa_key 29 | /etc/ssh/ssh_host_ecdsa_key.pub 30 | /etc/ssh/ssh_host_ed25519_key 31 | /etc/ssh/ssh_host_ed25519_key.pub 32 | /etc/ssh/ssh_host_rsa_key 33 | /etc/ssh/ssh_host_rsa_key.pub 34 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | 2 | 3 | services: 4 | ipaserver: 5 | image: ipaserver 6 | build: 7 | context: ./container 8 | dockerfile: Dockerfile.ipa 9 | args: 10 | IPA_DS_PASS: $IPA_DS_PASS 11 | IPA_ADMIN_PASS: $IPA_ADMIN_PASS 12 | hostname: ipa.mokey.local 13 | container_name: mokeyipaserver 14 | cgroup: host 15 | volumes: 16 | - /sys/fs/cgroup:/sys/fs/cgroup:rw 17 | - mokeyipa_data:/data 18 | stop_signal: RTMIN+3 19 | sysctls: 20 | - net.ipv6.conf.all.disable_ipv6=0 21 | 22 | ipaclient: 23 | image: ipaclient 24 | build: 25 | context: ./container 26 | dockerfile: Dockerfile.client 27 | args: 28 | DEV_SSH_KEY: $DEV_SSH_KEY 29 | GO_VERSION: $GO_VERSION 30 | USER_ID: ${UID:-1000} 31 | USER: ${USER:-developer} 32 | hostname: client.mokey.local 33 | container_name: mokeyipaclient 34 | cgroup: host 35 | volumes: 36 | - .:/app:cached 37 | - /sys/fs/cgroup:/sys/fs/cgroup:rw 38 | - mokeyclient_data:/data 39 | stop_signal: RTMIN+3 40 | links: 41 | - ipaserver:ipa.mokey.local 42 | ports: 43 | - "127.0.0.1:9023:22" 44 | - "0.0.0.0:8080:8080" 45 | 46 | volumes: 47 | mokeyipa_data: 48 | mokeyclient_data: 49 | -------------------------------------------------------------------------------- /docs/mokey-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/docs/mokey-logo.png -------------------------------------------------------------------------------- /docs/mokey-screenshot-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/docs/mokey-screenshot-home.png -------------------------------------------------------------------------------- /docs/mokey-screenshot-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/docs/mokey-screenshot-login.png -------------------------------------------------------------------------------- /examples/mokey-oidc/.gitignore: -------------------------------------------------------------------------------- 1 | mokey-oidc 2 | mokey-oidc.conf 3 | -------------------------------------------------------------------------------- /examples/mokey-oidc/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // This is an example OAuth 2.0 client app using OpenID Connect. It is not 4 | // meant for use in a production environment and only for testing 5 | // FreeIPA->mokey->hydra integration. Configuration is via environment 6 | // variables (see mokey-oidc.conf) 7 | // 8 | // This code was adopted from https://github.com/ory/hydra-consent-app-go 9 | 10 | import ( 11 | "context" 12 | "crypto/tls" 13 | "encoding/json" 14 | "fmt" 15 | "html/template" 16 | "net/http" 17 | "os" 18 | 19 | oidc "github.com/coreos/go-oidc" 20 | "github.com/gorilla/mux" 21 | "github.com/pkg/errors" 22 | "github.com/urfave/negroni" 23 | "golang.org/x/oauth2" 24 | ) 25 | 26 | // A state for performing the OAuth 2.0 flow 27 | var state = "mokeydemostate" 28 | 29 | var ( 30 | cert string 31 | key string 32 | authUrl *oauth2.Config 33 | homeTmpl *template.Template 34 | callbackTmpl *template.Template 35 | ) 36 | 37 | func main() { 38 | r := mux.NewRouter() 39 | r.HandleFunc("/", handleHome) 40 | r.HandleFunc("/callback", handleCallback) 41 | 42 | n := negroni.New() 43 | n.UseHandler(r) 44 | 45 | http.ListenAndServeTLS(fmt.Sprintf(":%s", os.Getenv("MOKEY_OIDC_PORT")), cert, key, n) 46 | fmt.Println(fmt.Sprintf("Listening on :%s", os.Getenv("MOKEY_OIDC_PORT"))) 47 | } 48 | 49 | func handleHome(w http.ResponseWriter, _ *http.Request) { 50 | var u = authUrl.AuthCodeURL(state) + "&nonce=" + state 51 | err := homeTmpl.Execute(w, u) 52 | if err != nil { 53 | http.Error(w, err.Error(), http.StatusInternalServerError) 54 | return 55 | } 56 | } 57 | 58 | // After the user gave his consent, they will hit this endpoint. The mokey 59 | // consent app includes some extra user info in the id_token. 60 | func handleCallback(w http.ResponseWriter, r *http.Request) { 61 | ctx := context.WithValue(context.Background(), oauth2.HTTPClient, &http.Client{ 62 | Transport: &http.Transport{ 63 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 64 | }, 65 | }) 66 | 67 | provider, err := oidc.NewProvider(ctx, os.Getenv("MOKEY_OIDC_PROVIDER")) 68 | if err != nil { 69 | http.Error(w, errors.Wrap(err, "Could not create provider").Error(), http.StatusBadRequest) 70 | return 71 | } 72 | 73 | oidcConfig := &oidc.Config{ 74 | ClientID: authUrl.ClientID, 75 | } 76 | verifier := provider.Verifier(oidcConfig) 77 | 78 | token, err := authUrl.Exchange(ctx, r.URL.Query().Get("code")) 79 | if err != nil { 80 | http.Error(w, errors.Wrap(err, "Could not exhange token").Error(), http.StatusBadRequest) 81 | return 82 | } 83 | 84 | rawIDToken, ok := token.Extra("id_token").(string) 85 | if !ok { 86 | http.Error(w, errors.Wrap(err, "No id_token field in oauth2 token").Error(), http.StatusBadRequest) 87 | return 88 | } 89 | idToken, err := verifier.Verify(ctx, rawIDToken) 90 | if err != nil { 91 | http.Error(w, errors.Wrap(err, "Failed to verify id token").Error(), http.StatusBadRequest) 92 | return 93 | } 94 | 95 | resp := struct { 96 | OAuth2Token *oauth2.Token 97 | IDTokenClaims *json.RawMessage 98 | }{token, new(json.RawMessage)} 99 | 100 | if err := idToken.Claims(&resp.IDTokenClaims); err != nil { 101 | http.Error(w, errors.Wrap(err, "Failed to get claims").Error(), http.StatusBadRequest) 102 | return 103 | } 104 | data, err := json.MarshalIndent(resp, "", " ") 105 | if err != nil { 106 | http.Error(w, err.Error(), http.StatusInternalServerError) 107 | return 108 | } 109 | 110 | err = callbackTmpl.Execute(w, struct { 111 | *oauth2.Token 112 | UserInfo string 113 | IDToken interface{} 114 | }{ 115 | Token: token, 116 | UserInfo: string(data), 117 | IDToken: token.Extra("id_token"), 118 | }) 119 | 120 | if err != nil { 121 | http.Error(w, err.Error(), http.StatusInternalServerError) 122 | return 123 | } 124 | } 125 | 126 | func init() { 127 | authUrl = &oauth2.Config{ 128 | ClientID: os.Getenv("MOKEY_OIDC_CLIENT_ID"), 129 | ClientSecret: os.Getenv("MOKEY_OIDC_CLIENT_SECRET"), 130 | Endpoint: oauth2.Endpoint{ 131 | TokenURL: os.Getenv("MOKEY_OIDC_TOKEN_URL"), 132 | AuthURL: os.Getenv("MOKEY_OIDC_AUTH_URL"), 133 | }, 134 | Scopes: []string{"openid"}, 135 | RedirectURL: os.Getenv("MOKEY_OIDC_REDIRECT_URL"), 136 | } 137 | 138 | cert = os.Getenv("MOKEY_OIDC_CERT") 139 | key = os.Getenv("MOKEY_OIDC_KEY") 140 | 141 | homeTmpl = template.Must(template.New("home.html").Parse(` 142 | 143 | 144 | 145 | Welcome! 146 | 147 | 148 |

149 | Click here to perform the exemplary authorize code flow. 150 |

151 | 152 | `)) 153 | 154 | callbackTmpl = template.Must(template.New("callback.html").Parse(` 155 | 156 | 157 | 158 | Success! 159 | 160 | 161 |

162 | OAuth2 authorize code flow was performed successfully! 163 |

164 |
165 |
AccessToken
166 |
{{.AccessToken}}
167 |
TokenType
168 |
{{.TokenType}}
169 |
RefreshToken
170 |
{{.RefreshToken}}
171 |
Expiry
172 |
{{.Expiry}}
173 |
ID Token
174 |
{{.IDToken}}
175 |
UserInfo
176 |
{{.UserInfo}}
177 |
178 |

179 | Do it again 180 |

181 | 182 | `)) 183 | } 184 | -------------------------------------------------------------------------------- /examples/mokey-oidc/mokey-oidc.conf.sample: -------------------------------------------------------------------------------- 1 | export MOKEY_OIDC_PORT=8081 2 | export MOKEY_OIDC_REDIRECT_URL="https://localhost:8081/callback" 3 | export MOKEY_OIDC_PROVIDER="https://hydra.local/" 4 | export MOKEY_OIDC_CLIENT_ID="mokey-test" 5 | export MOKEY_OIDC_CLIENT_SECRET="" 6 | export MOKEY_OIDC_TOKEN_URL="https://hydra.local/oauth2/token" 7 | export MOKEY_OIDC_AUTH_URL="https://hydra.local/oauth2/auth" 8 | export MOKEY_OIDC_CERT="/path/to/dev.crt" 9 | export MOKEY_OIDC_KEY="/path/to/dev.key" 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ubccr/mokey 2 | 3 | require ( 4 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 5 | github.com/coreos/go-oidc v2.2.1+incompatible 6 | github.com/dchest/captcha v1.0.0 7 | github.com/dustin/go-humanize v1.0.1 8 | github.com/essentialkaos/branca/v2 v2.0.5 9 | github.com/gofiber/fiber/v2 v2.52.5 10 | github.com/gofiber/storage/memory/v2 v2.0.1 11 | github.com/gofiber/storage/redis/v3 v3.1.2 12 | github.com/gofiber/storage/sqlite3/v2 v2.1.1 13 | github.com/gorilla/mux v1.8.1 14 | github.com/mileusna/useragent v1.3.5 15 | github.com/ory/hydra-client-go v1.10.6 16 | github.com/pkg/errors v0.9.1 17 | github.com/pquerna/otp v1.4.0 18 | github.com/prometheus/client_golang v1.19.1 19 | github.com/sirupsen/logrus v1.9.3 20 | github.com/spf13/cobra v1.8.1 21 | github.com/spf13/viper v1.19.0 22 | github.com/stretchr/testify v1.9.0 23 | github.com/ubccr/goipa v0.0.7 24 | github.com/urfave/negroni v1.0.0 25 | github.com/valyala/fasthttp v1.56.0 26 | golang.org/x/net v0.29.0 27 | golang.org/x/oauth2 v0.18.0 28 | ) 29 | 30 | require ( 31 | github.com/andybalholm/brotli v1.1.1 // indirect 32 | github.com/beorn7/perks v1.0.1 // indirect 33 | github.com/boombuler/barcode v1.0.1 // indirect 34 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 35 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 36 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 37 | github.com/fsnotify/fsnotify v1.7.0 // indirect 38 | github.com/go-ini/ini v1.67.0 // indirect 39 | github.com/go-logr/logr v1.4.1 // indirect 40 | github.com/go-logr/stdr v1.2.2 // indirect 41 | github.com/go-openapi/analysis v0.23.0 // indirect 42 | github.com/go-openapi/errors v0.22.0 // indirect 43 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 44 | github.com/go-openapi/jsonreference v0.21.0 // indirect 45 | github.com/go-openapi/loads v0.22.0 // indirect 46 | github.com/go-openapi/runtime v0.28.0 // indirect 47 | github.com/go-openapi/spec v0.21.0 // indirect 48 | github.com/go-openapi/strfmt v0.23.0 // indirect 49 | github.com/go-openapi/swag v0.23.0 // indirect 50 | github.com/go-openapi/validate v0.24.0 // indirect 51 | github.com/golang/protobuf v1.5.4 // indirect 52 | github.com/google/uuid v1.6.0 // indirect 53 | github.com/hashicorp/go-uuid v1.0.3 // indirect 54 | github.com/hashicorp/hcl v1.0.0 // indirect 55 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 56 | github.com/jcmturner/aescts/v2 v2.0.0 // indirect 57 | github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect 58 | github.com/jcmturner/gofork v1.7.6 // indirect 59 | github.com/jcmturner/goidentity/v6 v6.0.1 // indirect 60 | github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect 61 | github.com/jcmturner/rpc/v2 v2.0.3 // indirect 62 | github.com/josharian/intern v1.0.0 // indirect 63 | github.com/klauspost/compress v1.17.11 // indirect 64 | github.com/magiconair/properties v1.8.7 // indirect 65 | github.com/mailru/easyjson v0.7.7 // indirect 66 | github.com/mattn/go-colorable v0.1.13 // indirect 67 | github.com/mattn/go-isatty v0.0.20 // indirect 68 | github.com/mattn/go-runewidth v0.0.16 // indirect 69 | github.com/mattn/go-sqlite3 v1.14.24 // indirect 70 | github.com/mitchellh/mapstructure v1.5.0 // indirect 71 | github.com/oklog/ulid v1.3.1 // indirect 72 | github.com/opentracing/opentracing-go v1.2.0 // indirect 73 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 74 | github.com/philhofer/fwd v1.1.2 // indirect 75 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 76 | github.com/pquerna/cachecontrol v0.1.0 // indirect 77 | github.com/prometheus/client_model v0.6.1 // indirect 78 | github.com/prometheus/common v0.53.0 // indirect 79 | github.com/prometheus/procfs v0.15.0 // indirect 80 | github.com/redis/go-redis/v9 v9.7.0 // indirect 81 | github.com/rivo/uniseg v0.4.7 // indirect 82 | github.com/sagikazarmark/locafero v0.6.0 // indirect 83 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 84 | github.com/sourcegraph/conc v0.3.0 // indirect 85 | github.com/spf13/afero v1.11.0 // indirect 86 | github.com/spf13/cast v1.7.0 // indirect 87 | github.com/spf13/pflag v1.0.5 // indirect 88 | github.com/subosito/gotenv v1.6.0 // indirect 89 | github.com/tidwall/gjson v1.17.1 // indirect 90 | github.com/tidwall/match v1.1.1 // indirect 91 | github.com/tidwall/pretty v1.2.1 // indirect 92 | github.com/tinylib/msgp v1.1.9 // indirect 93 | github.com/valyala/bytebufferpool v1.0.0 // indirect 94 | github.com/valyala/tcplisten v1.0.0 // indirect 95 | go.mongodb.org/mongo-driver v1.15.0 // indirect 96 | go.opentelemetry.io/otel v1.26.0 // indirect 97 | go.opentelemetry.io/otel/metric v1.26.0 // indirect 98 | go.opentelemetry.io/otel/trace v1.26.0 // indirect 99 | go.uber.org/multierr v1.11.0 // indirect 100 | golang.org/x/crypto v0.28.0 // indirect 101 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect 102 | golang.org/x/sync v0.8.0 // indirect 103 | golang.org/x/sys v0.26.0 // indirect 104 | golang.org/x/text v0.19.0 // indirect 105 | google.golang.org/appengine v1.6.8 // indirect 106 | google.golang.org/protobuf v1.34.1 // indirect 107 | gopkg.in/ini.v1 v1.67.0 // indirect 108 | gopkg.in/square/go-jose.v2 v2.6.0 // indirect 109 | gopkg.in/yaml.v3 v3.0.1 // indirect 110 | ) 111 | 112 | go 1.22.0 113 | 114 | toolchain go1.23.2 115 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 mokey Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "github.com/ubccr/mokey/cmd" 9 | _ "github.com/ubccr/mokey/cmd/serve" 10 | ) 11 | 12 | func main() { 13 | cmd.Execute() 14 | } 15 | -------------------------------------------------------------------------------- /mokey.toml.sample: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------ 2 | # Sample Mokey Config 3 | #------------------------------------------------------------------------------ 4 | 5 | #------------------------------------------------------------------------------ 6 | # Site specific configuration 7 | #------------------------------------------------------------------------------ 8 | [site] 9 | # Name of your site 10 | name = "Identity Management" 11 | 12 | # Homepage of your organization 13 | homepage = "" 14 | 15 | # Link to your sites help pages 16 | help_url = "" 17 | 18 | # Link to your getting started guide 19 | getting_started_url = "" 20 | 21 | # Link to your terms of service 22 | tos_url = "" 23 | 24 | # Path to custom favicon.ico file 25 | favicon = "" 26 | 27 | # Path to logo 28 | logo = "" 29 | 30 | # Path to custom css styles file 31 | css = "" 32 | 33 | # Path to local template override directory. You can override one or more 34 | # of the templates using this directory 35 | # templates_dir = "/usr/share/mokey/templates" 36 | 37 | # Path to local static assets directory This is used to host all 38 | # css/javascript/images assets locally. Only used for advanced customization. 39 | # static_assets_dir = "/usr/share/mokey/assets" 40 | 41 | # User account for the mokey service 42 | ktuser = "mokeyapp" 43 | 44 | # Path to keytab file 45 | keytab = "/etc/mokey/private/mokeyapp.keytab" 46 | 47 | # Path to logo 48 | # logo = "/etc/mokey/assets/my-logo.png" 49 | 50 | #------------------------------------------------------------------------------ 51 | # User account settings 52 | #------------------------------------------------------------------------------ 53 | [accounts] 54 | # Default home directory 55 | default_homedir = "/home" 56 | 57 | # Default login shell 58 | default_shell = "/bin/bash" 59 | 60 | # Minimum password length. Used for validating new passwords. Should match your 61 | # password policy set in FreeIPA 62 | min_passwd_len = 8 63 | 64 | # Minimum password classes. Classes are lowercase, uppercase, numbers, and 65 | # special characters. Used for validating new passwords. Should match your 66 | # password policy set in FreeIPA 67 | min_passwd_classes = 2 68 | 69 | # Hash algorithm for generating OTP tokens: sha1, sha256, or sha512 70 | otp_hash_algorithm = "sha1" 71 | 72 | # Custom issuer name for OTP tokens. This creates a nice name for importing into authenticator apps 73 | otp_issuer = "MYORG" 74 | 75 | # Block list of user accounts from logging in 76 | # block_users = ["username1", "username2", "username3"] 77 | 78 | # Extract username from email address 79 | username_from_email = false 80 | 81 | # Allowed domains. Format is {"domain": "username-generator"}, where 82 | # username-generator can be one of the following username generator algorithms: 83 | # - default = takes username part from email 84 | # - flast = assumes emails are formated FirstName.LastName@example.com 85 | # Generates usernames using the first letter firstname + last name. 86 | # Example: John.Doe@example.com = jdoe 87 | # allowed_domains = {"example.edu" = "default", "example.com" = "flast"} 88 | 89 | # Require Two-Factor authentication on all accounts. This prevents users from 90 | # uploading ssh keys and displays a warning message reminding users to enable 91 | # Two-Factor authentication. 92 | require_mfa = false 93 | 94 | # Require FreeIPA admin to activate the account. With this option enabled new 95 | # accounts are disabled by default until a FreeIPA admin activates them. 96 | require_admin_verify = false 97 | 98 | # By default, login attempts for non-existent user accounts will be shown an 99 | # error message indicating that the username is not found in the system. If 100 | # your site is concerned about the potential for username enumeration attacks, 101 | # you could hide this error message by setting this to true. 102 | hide_invalid_username_error = false 103 | 104 | #------------------------------------------------------------------------------ 105 | # Email 106 | #------------------------------------------------------------------------------ 107 | [email] 108 | # Base URL used for email links. This should be the URL where mokey is being 109 | # hosted and defaults to the hostname used in the http request. Set this value 110 | # to hard code the base_url. 111 | # base_url = "https://localhost" 112 | 113 | # Max lifetime of branca tokens used for password resets and account verify 114 | token_max_age = 3600 115 | 116 | # Secret key for branca tokens. Must be 32 bytes. To generate run: 117 | # openssl rand -hex 32 118 | token_secret = "" 119 | 120 | # Hostname for smtp server 121 | smtp_host = "localhost" 122 | 123 | # Port for smtp server 124 | smtp_port = 25 125 | 126 | # Enable smtp tls 127 | smtp_tls = "off" 128 | 129 | # SMTP Authentication Credentials 130 | #smtp_username = "" 131 | #smtp_password = "" 132 | 133 | # Email signature to append to end of all emails 134 | signature = "" 135 | 136 | # From email address 137 | from = "support@example.com" 138 | 139 | #------------------------------------------------------------------------------ 140 | # Server settings 141 | #------------------------------------------------------------------------------ 142 | [server] 143 | # Address and port to listen 144 | listen = "0.0.0.0:8866" 145 | 146 | # Times out the session after inactivity (in seconds) 147 | session_idle_timeout = 900 148 | 149 | # Path to ssl certificate 150 | # ssl_cert = "" 151 | 152 | # Path to ssl key 153 | # ssl_key = "" 154 | 155 | # Require secure cookies 156 | secure_cookies = true 157 | 158 | # CSRF token secret key. Should be a random string 159 | csrf_secret = "" 160 | 161 | # Timeouts 162 | read_timeout = 5 163 | write_timeout = 5 164 | idle_timeout = 120 165 | 166 | # Rate limiter 167 | # Expiration time in seconds on how long to keep records of requests 168 | rate_limit_expiration = 60 169 | # Max number of recent connections during rate_limit_expiration seconds before sending a 429 response 170 | rate_limit_max = 25 171 | 172 | # Enable prometheus metrics endpoint. WARNING: there is no authentication 173 | # required for this endpoint and it's recommended to proxy this behind 174 | # something like nginx and enable appropriate access controls. 175 | enable_metrics = false 176 | 177 | #------------------------------------------------------------------------------ 178 | # Storage 179 | #------------------------------------------------------------------------------ 180 | [storage] 181 | # Storage driver. Supported drivers: memory, sqlite3, and redis 182 | driver = "memory" 183 | 184 | [storage.sqlite3] 185 | # Path to sqlite3 database used for session storage 186 | # dbpath = "/srv/mokey/storage/mokey.db" 187 | 188 | [storage.redis] 189 | # Redis URL 190 | # url = "redis://:@127.0.0.1:6379/" 191 | 192 | #------------------------------------------------------------------------------ 193 | # Hydra 194 | #------------------------------------------------------------------------------ 195 | [hydra] 196 | # admin_url: "http://locahost:4445" 197 | # login_timeout: 3600 198 | # fake_tls_termination: true 199 | -------------------------------------------------------------------------------- /scripts/nfpm/mokey.env: -------------------------------------------------------------------------------- 1 | MOKEY_ARGS="--config=/etc/mokey/mokey.toml --loglevel=info" 2 | 3 | # --loglevel="info" 4 | # Log level: trace,debug,info,warn,error 5 | -------------------------------------------------------------------------------- /scripts/nfpm/mokey.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=mokey server 3 | After=syslog.target network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=mokey 8 | Group=mokey 9 | EnvironmentFile=/etc/default/mokey 10 | WorkingDirectory=/var/lib/mokey 11 | ExecStart=/usr/bin/mokey serve $MOKEY_ARGS 12 | Restart=on-abort 13 | StateDirectory=mokey 14 | ConfigurationDirectory=mokey 15 | 16 | [Install] 17 | WantedBy=multi-user.target 18 | -------------------------------------------------------------------------------- /scripts/nfpm/mokey.toml.default: -------------------------------------------------------------------------------- 1 | #------------------------------------------------------------------------------ 2 | # Default Mokey Config 3 | #------------------------------------------------------------------------------ 4 | 5 | #------------------------------------------------------------------------------ 6 | # Site specific configuration 7 | #------------------------------------------------------------------------------ 8 | [site] 9 | # Name of your site 10 | name = "Identity Management" 11 | 12 | # Homepage of your organization 13 | homepage = "" 14 | 15 | # Link to your sites help pages 16 | help_url = "" 17 | 18 | # Link to your getting started guide 19 | getting_started_url = "" 20 | 21 | # Link to your terms of service 22 | tos_url = "" 23 | 24 | # User account for the mokey service 25 | ktuser = "mokeyapp" 26 | 27 | # Path to keytab file 28 | keytab = "/etc/mokey/private/mokeyapp.keytab" 29 | 30 | #------------------------------------------------------------------------------ 31 | # User account settings 32 | #------------------------------------------------------------------------------ 33 | [accounts] 34 | # Default home directory 35 | default_homedir = "/home" 36 | 37 | # Default login shell 38 | default_shell = "/bin/bash" 39 | 40 | # Minimum password length. Used for validating new passwords. Should match your 41 | # password policy set in FreeIPA 42 | min_passwd_len = 8 43 | 44 | # Minimum password classes. Classes are lowercase, uppercase, numbers, and 45 | # special characters. Used for validating new passwords. Should match your 46 | # password policy set in FreeIPA 47 | min_passwd_classes = 2 48 | 49 | # Hash algorithm for generating OTP tokens: sha1, sha256, or sha512 50 | otp_hash_algorithm = "sha1" 51 | 52 | # Custom issuer name for OTP tokens. This creates a nice name for importing into authenticator apps 53 | otp_issuer = "MYORG" 54 | 55 | # Block list of user accounts from logging in 56 | # block_users = ["username1", "username2", "username3"] 57 | 58 | #------------------------------------------------------------------------------ 59 | # Email 60 | #------------------------------------------------------------------------------ 61 | [email] 62 | # Max lifetime of branca tokens used for password resets and account verify 63 | token_max_age = 3600 64 | 65 | # Secret key for branca tokens. Must be 32 bytes. To generate run: 66 | # openssl rand -hex 32 67 | # token_secret = "" 68 | 69 | # Hostname for smtp server 70 | smtp_host = "localhost" 71 | 72 | # Port for smtp server 73 | smtp_port = 25 74 | 75 | # Enable smtp tls 76 | smtp_tls = "off" 77 | 78 | # From email address 79 | from = "support@example.com" 80 | 81 | #------------------------------------------------------------------------------ 82 | # Server settings 83 | #------------------------------------------------------------------------------ 84 | [server] 85 | # Address and port to listen 86 | listen = "0.0.0.0:8866" 87 | 88 | # Times out the session after inactivity (in seconds) 89 | session_idle_timeout = 900 90 | 91 | # Require secure cookies 92 | secure_cookies = true 93 | 94 | #------------------------------------------------------------------------------ 95 | # Storage 96 | #------------------------------------------------------------------------------ 97 | [storage] 98 | # Storage driver. Supported drivers: memory, sqlite3, and redis 99 | driver = "memory" 100 | -------------------------------------------------------------------------------- /scripts/nfpm/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | cleanInstall() { 4 | if ! getent passwd mokey > /dev/null; then 5 | printf "\033[32m Creating mokey system user & group\033[0m\n" 6 | groupadd -r mokey 7 | useradd -r -g mokey -d /var/lib/mokey -s /sbin/nologin \ 8 | -c 'Mokey server' mokey 9 | fi 10 | 11 | mkdir -p /var/lib/mokey 12 | chown mokey:mokey /var/lib/mokey 13 | chmod 755 /var/lib/mokey 14 | 15 | if [ -f "/etc/mokey/mokey.toml" ]; then 16 | chmod 640 /etc/mokey/mokey.toml 17 | chown mokey:mokey /etc/mokey/mokey.toml 18 | fi 19 | 20 | if [ -x "/usr/bin/deb-systemd-helper" ]; then 21 | deb-systemd-helper purge mokey.service >/dev/null 22 | deb-systemd-helper unmask mokey.service >/dev/null 23 | elif [ -x "/usr/bin/systemctl" ]; then 24 | systemctl daemon-reload ||: 25 | systemctl unmask mokey.service ||: 26 | systemctl preset mokey.service ||: 27 | systemctl enable mokey.service ||: 28 | fi 29 | } 30 | 31 | upgrade() { 32 | printf "\033[32m Upgrading mokey\033[0m\n" 33 | if [ -x "/usr/bin/systemctl" ]; then 34 | systemctl restart mokey.service ||: 35 | fi 36 | } 37 | 38 | # Step 2, check if this is a clean install or an upgrade 39 | action="$1" 40 | if [ "$1" = "configure" ] && [ -z "$2" ]; then 41 | # Alpine linux does not pass args, and deb passes $1=configure 42 | action="install" 43 | elif [ "$1" = "configure" ] && [ -n "$2" ]; then 44 | # deb passes $1=configure $2= 45 | action="upgrade" 46 | fi 47 | 48 | case "$action" in 49 | "1" | "install") 50 | cleanInstall 51 | ;; 52 | "2" | "upgrade") 53 | upgrade 54 | ;; 55 | *) 56 | # $1 == version being installed 57 | printf "\033[32m Alpine\033[0m" 58 | cleanInstall 59 | ;; 60 | esac 61 | 62 | exit 0 63 | -------------------------------------------------------------------------------- /server/auth.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/gofiber/fiber/v2" 9 | log "github.com/sirupsen/logrus" 10 | "github.com/spf13/viper" 11 | ipa "github.com/ubccr/goipa" 12 | ) 13 | 14 | func isBlocked(username string) bool { 15 | blockUsers := viper.GetStringSlice("accounts.block_users") 16 | for _, u := range blockUsers { 17 | if username == u { 18 | return true 19 | } 20 | } 21 | 22 | return false 23 | } 24 | 25 | func (r *Router) isLoggedIn(c *fiber.Ctx) (bool, error) { 26 | sess, err := r.session(c) 27 | if err != nil { 28 | return false, errors.New("Failed to get session") 29 | } 30 | 31 | username := sess.Get(SessionKeyUsername) 32 | sid := sess.Get(SessionKeySID) 33 | authenticated := sess.Get(SessionKeyAuthenticated) 34 | if sid == nil || username == nil || authenticated == nil { 35 | return false, errors.New("Invalid session") 36 | } 37 | 38 | if _, ok := username.(string); !ok { 39 | return false, errors.New("Invalid user in session") 40 | } 41 | 42 | if _, ok := sid.(string); !ok { 43 | return false, errors.New("Invalid sid in session") 44 | } 45 | 46 | if isAuthed, ok := authenticated.(bool); !ok || !isAuthed { 47 | return false, errors.New("User is not authenticated in session") 48 | } 49 | 50 | client := ipa.NewDefaultClientWithSession(sid.(string)) 51 | user, err := client.UserShow(username.(string)) 52 | if err != nil { 53 | return false, fmt.Errorf("Failed to refresh FreeIPA user session: %w", err) 54 | } 55 | 56 | c.Locals(ContextKeyUsername, username) 57 | c.Locals(ContextKeyUser, user) 58 | c.Locals(ContextKeyIPAClient, client) 59 | 60 | // Update session expiry time 61 | sess.SetExpiry(time.Duration(viper.GetInt("server.session_idle_timeout")) * time.Second) 62 | 63 | sess.Save() 64 | 65 | return true, nil 66 | } 67 | 68 | func (r *Router) Login(c *fiber.Ctx) error { 69 | vars := fiber.Map{} 70 | return c.Render("login.html", vars) 71 | } 72 | 73 | func (r *Router) Logout(c *fiber.Ctx) error { 74 | return r.redirectLogin(c) 75 | } 76 | 77 | func (r *Router) logout(c *fiber.Ctx) { 78 | sess, err := r.session(c) 79 | if err != nil { 80 | return 81 | } 82 | 83 | username := sess.Get(SessionKeyUsername) 84 | if username != nil { 85 | log.WithFields(log.Fields{ 86 | "username": username, 87 | "ip": RemoteIP(c), 88 | "path": c.Path(), 89 | }).Info("User logging out") 90 | } 91 | 92 | if err := sess.Destroy(); err != nil { 93 | log.WithFields(log.Fields{ 94 | "username": username, 95 | "ip": RemoteIP(c), 96 | "path": c.Path(), 97 | "err": err, 98 | }).Error("Logout failed to destroy session") 99 | } 100 | 101 | if viper.IsSet("hydra.admin_url") { 102 | if _, ok := username.(string); ok { 103 | err := r.revokeHydraAuthenticationSession(username.(string), c) 104 | if err != nil { 105 | log.WithFields(log.Fields{ 106 | "error": err, 107 | }).Error("Logout failed to revoke hydra authentication session") 108 | } 109 | } 110 | } 111 | } 112 | 113 | func (r *Router) redirectLogin(c *fiber.Ctx) error { 114 | r.logout(c) 115 | 116 | if c.Get("HX-Request", "false") == "true" { 117 | c.Set("HX-Redirect", "/auth/login") 118 | return c.Status(fiber.StatusNoContent).SendString("") 119 | } 120 | 121 | return c.Redirect("/auth/login") 122 | } 123 | 124 | func (r *Router) RequireNoLogin(c *fiber.Ctx) error { 125 | if ok, _ := r.isLoggedIn(c); ok { 126 | if c.Get("HX-Request", "false") == "true" { 127 | c.Set("HX-Redirect", "/") 128 | return c.Status(fiber.StatusNoContent).SendString("") 129 | } 130 | 131 | return c.Redirect("/") 132 | } 133 | 134 | return c.Next() 135 | } 136 | 137 | func (r *Router) RequireLogin(c *fiber.Ctx) error { 138 | if ok, err := r.isLoggedIn(c); !ok { 139 | log.WithFields(log.Fields{ 140 | "path": c.Path(), 141 | "ip": RemoteIP(c), 142 | "error": err, 143 | }).Info("Login required and no authenticated session found.") 144 | return r.redirectLogin(c) 145 | } 146 | 147 | return c.Next() 148 | } 149 | 150 | func (r *Router) RequireMFA(c *fiber.Ctx) error { 151 | if !viper.GetBool("accounts.require_mfa") { 152 | return c.Next() 153 | } 154 | 155 | user := r.user(c) 156 | if !user.OTPOnly() { 157 | return c.Status(fiber.StatusUnauthorized).SendString("You must enable Two-Factor Authentication first!") 158 | } 159 | 160 | return c.Next() 161 | } 162 | 163 | func (r *Router) CheckUser(c *fiber.Ctx) error { 164 | username := c.FormValue("username") 165 | 166 | if username == "" { 167 | return c.Status(fiber.StatusBadRequest).SendString("Please provide a username") 168 | } 169 | 170 | if isBlocked(username) { 171 | log.WithFields(log.Fields{ 172 | "username": username, 173 | }).Warn("AUDIT User account is blocked from logging in") 174 | r.metrics.totalFailedLogins.Inc() 175 | return c.Status(fiber.StatusUnauthorized).SendString("Invalid username") 176 | } 177 | 178 | userRec, err := r.adminClient.UserShow(username) 179 | if err != nil { 180 | if ierr, ok := err.(*ipa.IpaError); ok && ierr.Code == 4001 { 181 | log.WithFields(log.Fields{ 182 | "error": ierr, 183 | "username": username, 184 | }).Warn("Username not found in FreeIPA") 185 | 186 | if !viper.GetBool("accounts.hide_invalid_username_error") { 187 | r.metrics.totalFailedLogins.Inc() 188 | return c.Status(fiber.StatusUnauthorized).SendString("Invalid username") 189 | } 190 | userRec = new(ipa.User) 191 | userRec.Username = username 192 | } else { 193 | log.WithFields(log.Fields{ 194 | "error": err, 195 | "username": username, 196 | }).Error("Failed to fetch user info from FreeIPA") 197 | r.metrics.totalFailedLogins.Inc() 198 | return c.Status(fiber.StatusInternalServerError).SendString("Fatal system error") 199 | } 200 | } 201 | 202 | if userRec.Locked { 203 | log.WithFields(log.Fields{ 204 | "username": username, 205 | }).Warn("AUDIT User account is locked in FreeIPA") 206 | r.metrics.totalFailedLogins.Inc() 207 | return c.Status(fiber.StatusUnauthorized).SendString("User account is locked") 208 | } 209 | 210 | log.WithFields(log.Fields{ 211 | "username": username, 212 | "ip": RemoteIP(c), 213 | }).Info("Login user attempt") 214 | 215 | vars := fiber.Map{ 216 | "user": userRec, 217 | "challenge": c.FormValue("challenge"), 218 | } 219 | 220 | return c.Render("login-form.html", vars) 221 | } 222 | 223 | func (r *Router) Authenticate(c *fiber.Ctx) error { 224 | username := c.FormValue("username") 225 | password := c.FormValue("password") 226 | challenge := c.FormValue("challenge") 227 | otp := c.FormValue("otp") 228 | 229 | if username == "" { 230 | return c.Status(fiber.StatusBadRequest).SendString("Please provide a username") 231 | } 232 | 233 | if password == "" { 234 | return c.Status(fiber.StatusBadRequest).SendString("Please provide a password") 235 | } 236 | 237 | if isBlocked(username) { 238 | log.WithFields(log.Fields{ 239 | "username": username, 240 | }).Warn("AUDIT User account is blocked from logging in") 241 | r.metrics.totalFailedLogins.Inc() 242 | return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") 243 | } 244 | 245 | client := ipa.NewDefaultClient() 246 | err := client.RemoteLogin(username, password+otp) 247 | if err != nil { 248 | switch { 249 | case errors.Is(err, ipa.ErrExpiredPassword): 250 | log.WithFields(log.Fields{ 251 | "username": username, 252 | "err": err, 253 | }).Info("Password expired, forcing change") 254 | 255 | sess, err := r.session(c) 256 | if err != nil { 257 | return c.Status(fiber.StatusInternalServerError).SendString("") 258 | } 259 | 260 | err = sess.Regenerate() 261 | if err != nil { 262 | return err 263 | } 264 | 265 | sess.Set(SessionKeyAuthenticated, false) 266 | sess.Set(SessionKeyUsername, username) 267 | 268 | if err := r.sessionSave(c, sess); err != nil { 269 | return c.Status(fiber.StatusInternalServerError).SendString("") 270 | } 271 | 272 | userRec, err := r.adminClient.UserShow(username) 273 | if err != nil { 274 | return c.Status(fiber.StatusInternalServerError).SendString("") 275 | } 276 | 277 | vars := fiber.Map{ 278 | "username": username, 279 | "user": userRec, 280 | } 281 | return c.Render("login-password-expired.html", vars) 282 | default: 283 | log.WithFields(log.Fields{ 284 | "username": username, 285 | "ip": RemoteIP(c), 286 | "err": err, 287 | }).Error("AUDIT Failed login attempt") 288 | r.metrics.totalFailedLogins.Inc() 289 | return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") 290 | } 291 | } 292 | 293 | _, err = client.Ping() 294 | if err != nil { 295 | log.WithFields(log.Fields{ 296 | "username": username, 297 | "err": err, 298 | }).Error("Failed to ping FreeIPA") 299 | r.metrics.totalFailedLogins.Inc() 300 | return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") 301 | } 302 | 303 | sess, err := r.session(c) 304 | if err != nil { 305 | return err 306 | } 307 | 308 | err = sess.Regenerate() 309 | if err != nil { 310 | return err 311 | } 312 | 313 | sess.Set(SessionKeyAuthenticated, true) 314 | sess.Set(SessionKeyUsername, username) 315 | sess.Set(SessionKeySID, client.SessionID()) 316 | 317 | if err := r.sessionSave(c, sess); err != nil { 318 | return err 319 | } 320 | 321 | if viper.IsSet("hydra.admin_url") && challenge != "" { 322 | return r.LoginOAuthPost(username, challenge, c) 323 | } 324 | 325 | log.WithFields(log.Fields{ 326 | "username": username, 327 | "ip": RemoteIP(c), 328 | }).Info("AUDIT User logged in successfully") 329 | r.metrics.totalLogins.Inc() 330 | 331 | c.Set("HX-Redirect", "/") 332 | return c.Status(fiber.StatusNoContent).SendString("") 333 | } 334 | -------------------------------------------------------------------------------- /server/captcha.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | 7 | "github.com/dchest/captcha" 8 | "github.com/gofiber/fiber/v2" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // Captcha handler displays captcha image 13 | func (r *Router) Captcha(c *fiber.Ctx) error { 14 | id := c.Params("id") 15 | if id == "" { 16 | return c.Status(fiber.StatusNotFound).SendString("") 17 | } 18 | 19 | if c.FormValue("reload") != "" { 20 | captcha.Reload(id) 21 | } 22 | 23 | c.Append("Cache-Control", "no-cache, no-store, must-revalidate") 24 | c.Append("Pragma", "no-cache") 25 | c.Append("Expires", "0") 26 | 27 | var content bytes.Buffer 28 | c.Append(fiber.HeaderContentType, "image/png") 29 | err := captcha.WriteImage(&content, id, captcha.StdWidth, captcha.StdHeight) 30 | if err != nil { 31 | log.WithFields(log.Fields{ 32 | "id": id, 33 | }).Warn("Captcha not found") 34 | return c.Status(fiber.StatusNotFound).SendString("") 35 | } 36 | 37 | return c.SendStream(bytes.NewReader(content.Bytes())) 38 | } 39 | 40 | // Checks and verifies captcha 41 | 42 | func (r *Router) verifyCaptcha(id, sol string) error { 43 | if len(id) == 0 { 44 | return errors.New("Invalid captcha provided") 45 | } 46 | if len(sol) == 0 { 47 | return errors.New("Please type in the numbers you see in the picture") 48 | } 49 | 50 | if !captcha.VerifyString(id, sol) { 51 | return errors.New("The numbers you typed in do not match the image") 52 | } 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /server/const.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | var Version = "dev" 4 | 5 | const ( 6 | SessionKeyAuthenticated = "authenticated" 7 | SessionKeySID = "sid" 8 | SessionKeyUsername = "user" 9 | SessionKeyCSRF = "csrf" 10 | ContextKeyUser = "user" 11 | ContextKeyUsername = "username" 12 | ContextKeyIPAClient = "ipa" 13 | UserCategoryUnverified = "mokey-user-unverified" 14 | TokenAccountVerify = "verify" 15 | TokenPasswordReset = "reset" 16 | TokenUsedPrefix = "used-" 17 | TokenIssuedPrefix = "issued-" 18 | ) 19 | -------------------------------------------------------------------------------- /server/csrf.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | log "github.com/sirupsen/logrus" 6 | "github.com/spf13/viper" 7 | "golang.org/x/net/xsrftoken" 8 | ) 9 | 10 | func (r *Router) CSRF(c *fiber.Ctx) error { 11 | sess, err := r.session(c) 12 | if err != nil { 13 | return err 14 | } 15 | 16 | var token string 17 | csrf := sess.Get(SessionKeyCSRF) 18 | if _, ok := csrf.(string); ok { 19 | token = csrf.(string) 20 | } 21 | 22 | switch c.Method() { 23 | case fiber.MethodGet, fiber.MethodHead, fiber.MethodOptions, fiber.MethodTrace: 24 | if token == "" { 25 | token = xsrftoken.Generate(viper.GetString("server.csrf_secret"), "", "") 26 | sess.Set(SessionKeyCSRF, token) 27 | sess.Save() 28 | } 29 | default: 30 | if token == "" || token != c.Get("X-CSRF-Token") { 31 | log.WithFields(log.Fields{ 32 | "path": c.Path(), 33 | "ip": RemoteIP(c), 34 | }).Error("Invalid CSRF token in POST request") 35 | return fiber.ErrForbidden 36 | } 37 | } 38 | 39 | c.Locals(SessionKeyCSRF, token) 40 | 41 | return c.Next() 42 | } 43 | -------------------------------------------------------------------------------- /server/email.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 mokey Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD style 3 | // license that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "bytes" 9 | "crypto/tls" 10 | "fmt" 11 | "mime/multipart" 12 | "mime/quotedprintable" 13 | "net" 14 | "net/smtp" 15 | "net/textproto" 16 | "path/filepath" 17 | "strings" 18 | "text/template" 19 | "time" 20 | 21 | "github.com/dustin/go-humanize" 22 | "github.com/gofiber/fiber/v2" 23 | "github.com/mileusna/useragent" 24 | log "github.com/sirupsen/logrus" 25 | "github.com/spf13/viper" 26 | "github.com/ubccr/goipa" 27 | ) 28 | 29 | const crlf = "\r\n" 30 | 31 | type Emailer struct { 32 | templates *template.Template 33 | storage fiber.Storage 34 | } 35 | 36 | func BaseURL(ctx *fiber.Ctx) string { 37 | baseURL := viper.GetString("email.base_url") 38 | if baseURL == "" { 39 | baseURL = ctx.BaseURL() 40 | } 41 | 42 | return baseURL 43 | } 44 | 45 | func NewEmailer(storage fiber.Storage) (*Emailer, error) { 46 | tmpl := template.New("") 47 | tmpl.Funcs(funcMap) 48 | 49 | for _, ext := range []string{"txt", "html"} { 50 | tmpl, err := tmpl.ParseFS(templateFiles, "templates/email/*."+ext) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | localTemplatePath := filepath.Join(viper.GetString("site.templates_dir"), "email/*."+ext) 56 | localTemplates, err := filepath.Glob(localTemplatePath) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | if len(localTemplates) > 0 { 62 | tmpl, err = tmpl.ParseGlob(localTemplatePath) 63 | if err != nil { 64 | return nil, err 65 | } 66 | } 67 | } 68 | 69 | return &Emailer{storage: storage, templates: tmpl}, nil 70 | } 71 | 72 | func (e *Emailer) SendPasswordResetEmail(user *ipa.User, ctx *fiber.Ctx) error { 73 | token, err := NewToken(user.Username, user.Email, TokenPasswordReset, e.storage) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | vars := map[string]interface{}{ 79 | "link": fmt.Sprintf("%s/auth/resetpw/%s", BaseURL(ctx), token), 80 | "link_expires": strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(time.Duration(viper.GetInt("email.token_max_age"))*time.Second), "", "")), 81 | } 82 | 83 | return e.sendEmail(user, ctx, "Please reset your password", "password-reset", vars) 84 | } 85 | 86 | func (e *Emailer) SendAccountVerifyEmail(user *ipa.User, ctx *fiber.Ctx) error { 87 | token, err := NewToken(user.Username, user.Email, TokenAccountVerify, e.storage) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | vars := map[string]interface{}{ 93 | "link": fmt.Sprintf("%s/auth/verify/%s", BaseURL(ctx), token), 94 | "link_expires": strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(time.Duration(viper.GetInt("email.token_max_age"))*time.Second), "", "")), 95 | } 96 | 97 | return e.sendEmail(user, ctx, "Verify your email", "account-verify", vars) 98 | } 99 | 100 | func (e *Emailer) SendWelcomeEmail(user *ipa.User, ctx *fiber.Ctx) error { 101 | vars := map[string]interface{}{ 102 | "getting_started_url": viper.GetString("site.getting_started_url"), 103 | } 104 | 105 | subject := "Welcome to " + viper.GetString("site.name") 106 | 107 | return e.sendEmail(user, ctx, subject, "welcome", vars) 108 | } 109 | 110 | func (e *Emailer) SendMFAChangedEmail(enabled bool, user *ipa.User, ctx *fiber.Ctx) error { 111 | verb := "Disabled" 112 | if enabled { 113 | verb = "Enabled" 114 | } 115 | event := "Two-Factor Authentication " + verb 116 | 117 | vars := map[string]interface{}{ 118 | "event": event, 119 | } 120 | 121 | return e.sendEmail(user, ctx, event, "account-updated", vars) 122 | } 123 | 124 | func (e *Emailer) SendSSHKeyUpdatedEmail(added bool, user *ipa.User, ctx *fiber.Ctx) error { 125 | verb := "removed" 126 | if added { 127 | verb = "added" 128 | } 129 | event := "SSH key " + verb 130 | 131 | vars := map[string]interface{}{ 132 | "event": event, 133 | } 134 | 135 | return e.sendEmail(user, ctx, event, "account-updated", vars) 136 | } 137 | 138 | func (e *Emailer) SendOTPTokenUpdatedEmail(added bool, user *ipa.User, ctx *fiber.Ctx) error { 139 | verb := "removed" 140 | if added { 141 | verb = "added" 142 | } 143 | event := "OTP token " + verb 144 | 145 | vars := map[string]interface{}{ 146 | "event": event, 147 | } 148 | 149 | return e.sendEmail(user, ctx, event, "account-updated", vars) 150 | } 151 | 152 | func (e *Emailer) SendPasswordChangedEmail(user *ipa.User, ctx *fiber.Ctx) error { 153 | vars := map[string]interface{}{ 154 | "event": "Password changed", 155 | } 156 | 157 | return e.sendEmail(user, ctx, "Your password has been changed", "account-updated", vars) 158 | } 159 | 160 | func (e *Emailer) quotedBody(body []byte) ([]byte, error) { 161 | var buf bytes.Buffer 162 | w := quotedprintable.NewWriter(&buf) 163 | _, err := w.Write(body) 164 | if err != nil { 165 | return nil, err 166 | } 167 | 168 | err = w.Close() 169 | if err != nil { 170 | return nil, err 171 | } 172 | 173 | return buf.Bytes(), nil 174 | } 175 | 176 | func (e *Emailer) sendEmail(user *ipa.User, ctx *fiber.Ctx, subject, tmpl string, data map[string]interface{}) error { 177 | log.WithFields(log.Fields{ 178 | "email": user.Email, 179 | "username": user.Username, 180 | }).Debug("Sending email to user") 181 | 182 | if data == nil { 183 | data = make(map[string]interface{}) 184 | } 185 | 186 | ua := useragent.Parse(ctx.Get(fiber.HeaderUserAgent)) 187 | 188 | data["os"] = ua.OS 189 | data["browser"] = ua.Name 190 | data["user"] = user 191 | data["date"] = time.Now() 192 | data["contact"] = viper.GetString("email.from") 193 | data["sig"] = viper.GetString("email.signature") 194 | data["site_name"] = viper.GetString("site.name") 195 | data["help_url"] = viper.GetString("site.help_url") 196 | data["homepage"] = viper.GetString("site.homepage") 197 | data["base_url"] = BaseURL(ctx) 198 | 199 | var text bytes.Buffer 200 | err := e.templates.ExecuteTemplate(&text, tmpl+".txt", data) 201 | if err != nil { 202 | return err 203 | } 204 | 205 | txtBody, err := e.quotedBody(text.Bytes()) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | var html bytes.Buffer 211 | err = e.templates.ExecuteTemplate(&html, tmpl+".html", data) 212 | if err != nil { 213 | return err 214 | } 215 | 216 | htmlBody, err := e.quotedBody(html.Bytes()) 217 | if err != nil { 218 | return err 219 | } 220 | 221 | header := make(textproto.MIMEHeader) 222 | header.Set("Mime-Version", "1.0") 223 | header.Set("Date", time.Now().Format(time.RFC1123Z)) 224 | header.Set("To", user.Email) 225 | header.Set("Subject", fmt.Sprintf("[%s] %s", viper.GetString("site.name"), subject)) 226 | header.Set("From", viper.GetString("email.from")) 227 | 228 | var multipartBody bytes.Buffer 229 | mp := multipart.NewWriter(&multipartBody) 230 | header.Set("Content-Type", fmt.Sprintf("multipart/alternative;%s boundary=%s", crlf, mp.Boundary())) 231 | 232 | txtPart, err := mp.CreatePart(textproto.MIMEHeader( 233 | map[string][]string{ 234 | "Content-Type": []string{"text/plain; charset=utf-8"}, 235 | "Content-Transfer-Encoding": []string{"quoted-printable"}, 236 | })) 237 | if err != nil { 238 | return err 239 | } 240 | 241 | _, err = txtPart.Write(txtBody) 242 | if err != nil { 243 | return err 244 | } 245 | 246 | htmlPart, err := mp.CreatePart(textproto.MIMEHeader( 247 | map[string][]string{ 248 | "Content-Type": []string{"text/html; charset=utf-8"}, 249 | "Content-Transfer-Encoding": []string{"quoted-printable"}, 250 | })) 251 | if err != nil { 252 | return err 253 | } 254 | 255 | _, err = htmlPart.Write(htmlBody) 256 | if err != nil { 257 | return err 258 | } 259 | 260 | err = mp.Close() 261 | if err != nil { 262 | return err 263 | } 264 | 265 | smtpHostPort := fmt.Sprintf("%s:%d", viper.GetString("email.smtp_host"), viper.GetInt("email.smtp_port")) 266 | var conn net.Conn 267 | tlsMode := viper.GetString("email.smtp_tls") 268 | 269 | switch tlsMode { 270 | case "on": 271 | tlsConfig := &tls.Config{ 272 | InsecureSkipVerify: false, 273 | ServerName: viper.GetString("email.smtp_host"), 274 | } 275 | conn, err = tls.Dial("tcp", smtpHostPort, tlsConfig) 276 | case "off", "starttls": 277 | conn, err = net.Dial("tcp", smtpHostPort) 278 | default: 279 | return fmt.Errorf("invalid config value for smtp_tls: %s", tlsMode) 280 | } 281 | 282 | if err != nil { 283 | return err 284 | } 285 | 286 | c, err := smtp.NewClient(conn, viper.GetString("email.smtp_host")) 287 | if err != nil { 288 | return err 289 | } 290 | defer c.Close() 291 | 292 | if tlsMode == "starttls" { 293 | err := c.StartTLS(&tls.Config{ 294 | ServerName: viper.GetString("email.smtp_host"), 295 | }) 296 | if err != nil { 297 | return err 298 | } 299 | } 300 | 301 | if viper.IsSet("email.smtp_username") && viper.IsSet("email.smtp_password") { 302 | auth := smtp.PlainAuth("", viper.GetString("email.smtp_username"), viper.GetString("email.smtp_password"), viper.GetString("email.smtp_host")) 303 | if err = c.Auth(auth); err != nil { 304 | log.Error(err) 305 | return err 306 | } 307 | } 308 | if err = c.Mail(viper.GetString("email.from")); err != nil { 309 | log.Error(err) 310 | return err 311 | } 312 | if err = c.Rcpt(user.Email); err != nil { 313 | log.Error(err) 314 | return err 315 | } 316 | 317 | wc, err := c.Data() 318 | if err != nil { 319 | return err 320 | } 321 | defer wc.Close() 322 | 323 | var buf bytes.Buffer 324 | for k, vv := range header { 325 | for _, v := range vv { 326 | fmt.Fprintf(&buf, "%s: %s\r\n", k, v) 327 | } 328 | } 329 | fmt.Fprintf(&buf, "\r\n") 330 | 331 | if _, err = buf.WriteTo(wc); err != nil { 332 | return err 333 | } 334 | if _, err = wc.Write(multipartBody.Bytes()); err != nil { 335 | return err 336 | } 337 | 338 | return nil 339 | } 340 | -------------------------------------------------------------------------------- /server/hydra.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/ory/hydra-client-go/client/admin" 9 | "github.com/ory/hydra-client-go/models" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | type FakeTLSTransport struct { 15 | T http.RoundTripper 16 | } 17 | 18 | func (ftt *FakeTLSTransport) RoundTrip(req *http.Request) (*http.Response, error) { 19 | req.Header.Add("X-Forwarded-Proto", "https") 20 | return ftt.T.RoundTrip(req) 21 | } 22 | 23 | func (r *Router) ConsentGet(c *fiber.Ctx) error { 24 | // Get the challenge from the query. 25 | challenge := c.Query("consent_challenge") 26 | if challenge == "" { 27 | log.WithFields(log.Fields{ 28 | "ip": RemoteIP(c), 29 | }).Error("Consent endpoint was called without a consent challenge") 30 | r.metrics.totalHydraFailedLogins.Inc() 31 | return c.Status(fiber.StatusBadRequest).SendString("consent without challenge") 32 | } 33 | 34 | cparams := admin.NewGetConsentRequestParams() 35 | cparams.SetConsentChallenge(challenge) 36 | cparams.SetHTTPClient(r.hydraAdminHTTPClient) 37 | cresponse, err := r.hydraClient.Admin.GetConsentRequest(cparams) 38 | if err != nil { 39 | log.WithFields(log.Fields{ 40 | "error": err, 41 | }).Error("Failed to validate the consent challenge") 42 | r.metrics.totalHydraFailedLogins.Inc() 43 | return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate consent") 44 | } 45 | 46 | consent := cresponse.Payload 47 | 48 | user, err := r.adminClient.UserShow(consent.Subject) 49 | if err != nil { 50 | log.WithFields(log.Fields{ 51 | "error": err, 52 | "username": consent.Subject, 53 | }).Warn("Failed to find User record for consent") 54 | r.metrics.totalHydraFailedLogins.Inc() 55 | return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate consent") 56 | } 57 | 58 | if viper.GetBool("accounts.require_mfa") && !user.OTPOnly() { 59 | r.metrics.totalHydraFailedLogins.Inc() 60 | return c.Status(fiber.StatusUnauthorized).SendString("Access denied.") 61 | } 62 | 63 | params := admin.NewAcceptConsentRequestParams() 64 | params.SetConsentChallenge(challenge) 65 | params.SetHTTPClient(r.hydraAdminHTTPClient) 66 | params.SetBody(&models.AcceptConsentRequest{ 67 | GrantScope: consent.RequestedScope, 68 | Session: &models.ConsentRequestSession{ 69 | IDToken: map[string]interface{}{ 70 | "uid": string(user.Username), 71 | "first": string(user.First), 72 | "last": string(user.Last), 73 | "given_name": string(user.First), 74 | "family_name": string(user.Last), 75 | "groups": strings.Join(user.Groups, ";"), 76 | "email": string(user.Email), 77 | }, 78 | }}) 79 | 80 | response, err := r.hydraClient.Admin.AcceptConsentRequest(params) 81 | if err != nil { 82 | log.WithFields(log.Fields{ 83 | "error": err, 84 | }).Error("Failed to accept the consent challenge") 85 | r.metrics.totalHydraFailedLogins.Inc() 86 | return c.Status(fiber.StatusInternalServerError).SendString("Failed to accept consent") 87 | } 88 | 89 | log.WithFields(log.Fields{ 90 | "username": consent.Subject, 91 | }).Info("AUDIT User logged in via Hydra OAuth2 successfully") 92 | r.metrics.totalHydraLogins.Inc() 93 | 94 | c.Set("HX-Redirect", *response.Payload.RedirectTo) 95 | return c.Redirect(*response.Payload.RedirectTo) 96 | } 97 | 98 | func (r *Router) LoginOAuthGet(c *fiber.Ctx) error { 99 | // Get the challenge from the query. 100 | challenge := c.Query("login_challenge") 101 | if challenge == "" { 102 | log.WithFields(log.Fields{ 103 | "ip": RemoteIP(c), 104 | }).Error("Login OAuth endpoint was called without a challenge") 105 | return c.Status(fiber.StatusBadRequest).SendString("login without challenge") 106 | } 107 | 108 | getparams := admin.NewGetLoginRequestParams() 109 | getparams.SetLoginChallenge(challenge) 110 | getparams.SetHTTPClient(r.hydraAdminHTTPClient) 111 | response, err := r.hydraClient.Admin.GetLoginRequest(getparams) 112 | if err != nil { 113 | log.WithFields(log.Fields{ 114 | "error": err, 115 | }).Error("Failed to validate the login challenge") 116 | return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate login") 117 | } 118 | 119 | login := response.Payload 120 | 121 | if *login.Skip { 122 | log.WithFields(log.Fields{ 123 | "user": *login.Subject, 124 | }).Debug("Hydra requested we skip login") 125 | 126 | // Check to make sure we have a valid user id 127 | user, err := r.adminClient.UserShow(*login.Subject) 128 | if err != nil { 129 | log.WithFields(log.Fields{ 130 | "error": err, 131 | "username": *login.Subject, 132 | }).Warn("Failed to find User record for login") 133 | r.metrics.totalHydraFailedLogins.Inc() 134 | return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate login") 135 | } 136 | 137 | if viper.GetBool("accounts.require_mfa") && !user.OTPOnly() { 138 | r.metrics.totalHydraFailedLogins.Inc() 139 | return c.Status(fiber.StatusUnauthorized).SendString("Access denied.") 140 | } 141 | 142 | acceptparams := admin.NewAcceptLoginRequestParams() 143 | acceptparams.SetLoginChallenge(challenge) 144 | acceptparams.SetHTTPClient(r.hydraAdminHTTPClient) 145 | acceptparams.SetBody(&models.AcceptLoginRequest{ 146 | Subject: login.Subject, 147 | }) 148 | 149 | completedResponse, err := r.hydraClient.Admin.AcceptLoginRequest(acceptparams) 150 | if err != nil { 151 | log.WithFields(log.Fields{ 152 | "error": err, 153 | }).Error("Failed to accept the GET login challenge") 154 | r.metrics.totalHydraFailedLogins.Inc() 155 | return c.Status(fiber.StatusInternalServerError).SendString("Failed to accept login") 156 | } 157 | 158 | log.WithFields(log.Fields{ 159 | "username": *login.Subject, 160 | }).Debug("Hydra OAuth login GET challenge signed successfully") 161 | 162 | c.Set("HX-Redirect", *completedResponse.Payload.RedirectTo) 163 | return c.Redirect(*completedResponse.Payload.RedirectTo) 164 | } 165 | 166 | if ok, _ := r.isLoggedIn(c); ok { 167 | return r.LoginOAuthPost(r.username(c), challenge, c) 168 | } 169 | 170 | vars := fiber.Map{ 171 | "challenge": challenge, 172 | } 173 | 174 | return c.Render("login.html", vars) 175 | } 176 | 177 | func (r *Router) LoginOAuthPost(username, challenge string, c *fiber.Ctx) error { 178 | acceptparams := admin.NewAcceptLoginRequestParams() 179 | acceptparams.SetLoginChallenge(challenge) 180 | acceptparams.SetHTTPClient(r.hydraAdminHTTPClient) 181 | acceptparams.SetBody(&models.AcceptLoginRequest{ 182 | Subject: &username, 183 | Remember: true, // TODO: make this configurable 184 | RememberFor: viper.GetInt64("hydra.login_timeout"), 185 | }) 186 | 187 | completedResponse, err := r.hydraClient.Admin.AcceptLoginRequest(acceptparams) 188 | if err != nil { 189 | log.WithFields(log.Fields{ 190 | "username": username, 191 | "error": err, 192 | }).Error("Failed to accept the POST login challenge") 193 | return c.Status(fiber.StatusInternalServerError).SendString("Failed to accept login") 194 | } 195 | 196 | log.WithFields(log.Fields{ 197 | "username": username, 198 | }).Debug("Hydra OAuth2 login POST challenge signed successfully") 199 | 200 | if c.Get("HX-Request", "false") == "true" { 201 | c.Set("HX-Redirect", *completedResponse.Payload.RedirectTo) 202 | return c.Status(fiber.StatusNoContent).SendString("") 203 | } 204 | 205 | return c.Redirect(*completedResponse.Payload.RedirectTo) 206 | } 207 | 208 | func (r *Router) HydraError(c *fiber.Ctx) error { 209 | message := c.Query("error") 210 | desc := c.Query("error_description") 211 | hint := c.Query("error_hint") 212 | 213 | log.WithFields(log.Fields{ 214 | "message": message, 215 | "desc": desc, 216 | "hint": hint, 217 | }).Error("OAuth2 request failed") 218 | 219 | return c.Status(fiber.StatusInternalServerError).SendString("OAuth2 Error") 220 | } 221 | 222 | func (r *Router) revokeHydraAuthenticationSession(username string, c *fiber.Ctx) error { 223 | params := admin.NewRevokeAuthenticationSessionParams() 224 | params.SetSubject(username) 225 | params.SetHTTPClient(r.hydraAdminHTTPClient) 226 | _, err := r.hydraClient.Admin.RevokeAuthenticationSession(params) 227 | if err != nil { 228 | return err 229 | } 230 | 231 | log.WithFields(log.Fields{ 232 | "user": username, 233 | }).Info("Successfully revoked hydra authentication session") 234 | 235 | return nil 236 | } 237 | -------------------------------------------------------------------------------- /server/metrics.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/prometheus/client_golang/prometheus" 6 | "github.com/prometheus/client_golang/prometheus/promauto" 7 | "github.com/prometheus/client_golang/prometheus/promhttp" 8 | "github.com/valyala/fasthttp" 9 | "github.com/valyala/fasthttp/fasthttpadaptor" 10 | ) 11 | 12 | type Metrics struct { 13 | handler fasthttp.RequestHandler 14 | totalLogins prometheus.Counter 15 | totalFailedLogins prometheus.Counter 16 | totalHydraLogins prometheus.Counter 17 | totalHydraFailedLogins prometheus.Counter 18 | totalSignups prometheus.Counter 19 | totalPasswordResets prometheus.Counter 20 | totalPasswordResetsSent prometheus.Counter 21 | totalAccountVerifications prometheus.Counter 22 | totalAccountVerificationsSent prometheus.Counter 23 | } 24 | 25 | func NewMetrics() *Metrics { 26 | m := &Metrics{ 27 | totalLogins: promauto.NewCounter(prometheus.CounterOpts{ 28 | Name: "mokey_logins_total", 29 | Help: "The total number of successful logins", 30 | }), 31 | totalFailedLogins: promauto.NewCounter(prometheus.CounterOpts{ 32 | Name: "mokey_logins_failed_total", 33 | Help: "The total number of failed logins", 34 | }), 35 | totalHydraLogins: promauto.NewCounter(prometheus.CounterOpts{ 36 | Name: "mokey_hydra_logins_total", 37 | Help: "The total number of successful Hydra logins", 38 | }), 39 | totalHydraFailedLogins: promauto.NewCounter(prometheus.CounterOpts{ 40 | Name: "mokey_hydra_logins_failed_total", 41 | Help: "The total number of failed Hydra logins", 42 | }), 43 | totalSignups: promauto.NewCounter(prometheus.CounterOpts{ 44 | Name: "mokey_signups_total", 45 | Help: "The total number of new accounts created", 46 | }), 47 | totalPasswordResets: promauto.NewCounter(prometheus.CounterOpts{ 48 | Name: "mokey_password_reset_total", 49 | Help: "The total number of successfull password resets", 50 | }), 51 | totalPasswordResetsSent: promauto.NewCounter(prometheus.CounterOpts{ 52 | Name: "mokey_password_reset_sent_total", 53 | Help: "The total number of password reset emails sent", 54 | }), 55 | totalAccountVerifications: promauto.NewCounter(prometheus.CounterOpts{ 56 | Name: "mokey_account_verification_total", 57 | Help: "The total number of successfull account verifications", 58 | }), 59 | totalAccountVerificationsSent: promauto.NewCounter(prometheus.CounterOpts{ 60 | Name: "mokey_account_verification_sent_total", 61 | Help: "The total number of account verification emails sent", 62 | }), 63 | } 64 | 65 | m.handler = fasthttpadaptor.NewFastHTTPHandler(promhttp.Handler()) 66 | 67 | return m 68 | } 69 | 70 | func (m *Metrics) Handler(c *fiber.Ctx) error { 71 | m.handler(c.Context()) 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /server/middleware.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 mokey Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD style 3 | // license that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "fmt" 9 | "strings" 10 | 11 | "github.com/gofiber/fiber/v2" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | func SecureHeaders(c *fiber.Ctx) error { 16 | c.Set(fiber.HeaderXXSSProtection, "1; mode=block") 17 | c.Set(fiber.HeaderXContentTypeOptions, "nosniff") 18 | c.Set(fiber.HeaderXFrameOptions, "DENY") 19 | c.Set(fiber.HeaderContentSecurityPolicy, "default-src 'self' 'unsafe-inline'; img-src 'self' data:;script-src 'self' 'unsafe-inline'") 20 | 21 | if !strings.HasPrefix(c.Path(), "/static") { 22 | c.Set("Cache-Control", "no-store") 23 | c.Set("Pragma", "no-cache") 24 | } 25 | return c.Next() 26 | } 27 | 28 | func NotFoundHandler(c *fiber.Ctx) error { 29 | log.WithFields(log.Fields{ 30 | "path": c.Path(), 31 | "ip": RemoteIP(c), 32 | }).Info("Requested path not found") 33 | 34 | if c.Get("HX-Request", "false") == "true" { 35 | err := c.Render("404-partial.html", nil) 36 | if err != nil { 37 | log.WithFields(log.Fields{ 38 | "error": err, 39 | }).Error("Failed to render custom error partial") 40 | return c.Status(fiber.StatusNotFound).SendString("") 41 | } 42 | return nil 43 | } 44 | 45 | return c.Render("404.html", fiber.Map{}) 46 | } 47 | 48 | func HTTPErrorHandler(c *fiber.Ctx, err error) error { 49 | username := c.Locals(ContextKeyUser) 50 | path := c.Path() 51 | code := fiber.StatusInternalServerError 52 | 53 | if e, ok := err.(*fiber.Error); ok { 54 | code = e.Code 55 | } 56 | 57 | log.WithFields(log.Fields{ 58 | "code": code, 59 | "username": username, 60 | "path": path, 61 | "ip": RemoteIP(c), 62 | }).Error(err) 63 | 64 | if c.Locals("NoErrorTemplate") == "true" { 65 | return c.Status(code).SendString("") 66 | } 67 | 68 | if c.Get("HX-Request", "false") == "true" { 69 | errorPage := fmt.Sprintf("%d-partial.html", code) 70 | err := c.Render(errorPage, nil) 71 | if err != nil { 72 | log.WithFields(log.Fields{ 73 | "error": err, 74 | }).Error("Failed to render custom error partial") 75 | return c.Status(code).SendString("") 76 | } 77 | return nil 78 | } 79 | 80 | errorPage := fmt.Sprintf("%d.html", code) 81 | err = c.Render(errorPage, nil) 82 | if err != nil { 83 | log.WithFields(log.Fields{ 84 | "error": err, 85 | }).Error("Failed to render custom error page") 86 | return c.Status(code).SendString("") 87 | } 88 | 89 | return nil 90 | } 91 | 92 | func LimitReachedHandler(c *fiber.Ctx) error { 93 | log.WithFields(log.Fields{ 94 | "ip": RemoteIP(c), 95 | }).Warn("Limit reached") 96 | return c.Status(fiber.StatusTooManyRequests).SendString("Too many requests. Please try again later.") 97 | } 98 | 99 | func (r *Router) RequireHTMX(c *fiber.Ctx) error { 100 | if c.Get("HX-Request", "false") == "true" { 101 | return c.Next() 102 | } 103 | 104 | return c.Status(fiber.StatusBadRequest).SendString("") 105 | } 106 | -------------------------------------------------------------------------------- /server/otp.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/gofiber/fiber/v2" 8 | "github.com/pquerna/otp" 9 | "github.com/pquerna/otp/totp" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/viper" 12 | "github.com/ubccr/goipa" 13 | ) 14 | 15 | func getHashAlgorithm() otp.Algorithm { 16 | algo := viper.GetString("accounts.otp_hash_algorithm") 17 | switch algo { 18 | case "sha256": 19 | return otp.AlgorithmSHA256 20 | case "sha512": 21 | return otp.AlgorithmSHA512 22 | default: 23 | return otp.AlgorithmSHA1 24 | } 25 | } 26 | 27 | func (r *Router) tokenList(c *fiber.Ctx, vars fiber.Map) error { 28 | client := r.userClient(c) 29 | user := r.user(c) 30 | 31 | tokens, err := client.FetchOTPTokens(user.Username) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | vars["otptokens"] = tokens 37 | vars["user"] = user 38 | return c.Render("otptoken-list.html", vars) 39 | } 40 | 41 | func (r *Router) OTPTokenList(c *fiber.Ctx) error { 42 | return r.tokenList(c, fiber.Map{}) 43 | } 44 | 45 | func (r *Router) OTPTokenModal(c *fiber.Ctx) error { 46 | vars := fiber.Map{} 47 | return c.Render("otptoken-new.html", vars) 48 | } 49 | 50 | func (r *Router) OTPTokenRemove(c *fiber.Ctx) error { 51 | uuid := c.FormValue("uuid") 52 | client := r.userClient(c) 53 | user := r.user(c) 54 | vars := fiber.Map{} 55 | 56 | err := client.RemoveOTPToken(uuid) 57 | if err != nil { 58 | log.WithFields(log.Fields{ 59 | "uuid": uuid, 60 | "username": user.Username, 61 | "err": err, 62 | }).Error("Failed to remove OTP token") 63 | 64 | if ierr, ok := err.(*ipa.IpaError); ok && ierr.Code == 4203 { 65 | vars["message"] = "You can't remove your last active token while Two-Factor auth is enabled" 66 | } else { 67 | vars["message"] = "Failed to remove token" 68 | } 69 | } else { 70 | err = r.emailer.SendOTPTokenUpdatedEmail(false, user, c) 71 | if err != nil { 72 | log.WithFields(log.Fields{ 73 | "err": err, 74 | "username": user.Username, 75 | }).Error("Failed to send otp token removed email") 76 | } 77 | } 78 | 79 | return r.tokenList(c, vars) 80 | } 81 | 82 | func (r *Router) OTPTokenEnable(c *fiber.Ctx) error { 83 | uuid := c.FormValue("uuid") 84 | client := r.userClient(c) 85 | username := r.username(c) 86 | vars := fiber.Map{} 87 | 88 | err := client.EnableOTPToken(uuid) 89 | if err != nil { 90 | log.WithFields(log.Fields{ 91 | "uuid": uuid, 92 | "username": username, 93 | "err": err, 94 | }).Error("Failed to enable OTP token") 95 | vars["message"] = "Failed to enable token" 96 | } 97 | 98 | return r.tokenList(c, vars) 99 | } 100 | 101 | func (r *Router) OTPTokenDisable(c *fiber.Ctx) error { 102 | uuid := c.FormValue("uuid") 103 | client := r.userClient(c) 104 | username := r.username(c) 105 | vars := fiber.Map{} 106 | 107 | err := client.DisableOTPToken(uuid) 108 | if err != nil { 109 | log.WithFields(log.Fields{ 110 | "uuid": uuid, 111 | "username": username, 112 | "err": err, 113 | }).Error("Failed to enable OTP token") 114 | 115 | if ierr, ok := err.(*ipa.IpaError); ok && ierr.Code == 4203 { 116 | vars["message"] = "You can't disable your last active token while Two-Factor auth is enabled" 117 | } else { 118 | vars["message"] = "Failed to disable token" 119 | } 120 | } 121 | 122 | return r.tokenList(c, vars) 123 | } 124 | 125 | func (r *Router) OTPTokenVerify(c *fiber.Ctx) error { 126 | otpcode := c.FormValue("otpcode") 127 | uri := c.FormValue("uri") 128 | uuid := c.FormValue("uuid") 129 | action := c.FormValue("action") 130 | client := r.userClient(c) 131 | user := r.user(c) 132 | vars := fiber.Map{} 133 | 134 | key, err := otp.NewKeyFromURL(uri) 135 | if err != nil || action == "cancel" { 136 | client.RemoveOTPToken(uuid) 137 | vars["message"] = "Failed to verify token." 138 | return r.tokenList(c, vars) 139 | } 140 | 141 | valid, _ := totp.ValidateCustom( 142 | otpcode, 143 | key.Secret(), 144 | time.Now().UTC(), 145 | totp.ValidateOpts{ 146 | Period: 30, 147 | Skew: 1, 148 | Digits: otp.DigitsSix, 149 | Algorithm: getHashAlgorithm(), 150 | }, 151 | ) 152 | if !valid { 153 | log.WithFields(log.Fields{ 154 | "uuid": uuid, 155 | "username": user.Username, 156 | }).Error("Failed to verify OTP token") 157 | return c.Status(fiber.StatusBadRequest).SendString("Invalid 6-digit code. Please try again.") 158 | } 159 | 160 | autoMFA := false 161 | if viper.GetBool("accounts.require_mfa") { 162 | tokens, _ := client.FetchOTPTokens(user.Username) 163 | // Enable Two-Factor auth automatically if user only has single token 164 | if !user.OTPOnly() && len(tokens) == 1 { 165 | otpOnly := []string{"otp"} 166 | err = r.adminClient.SetAuthTypes(user.Username, otpOnly) 167 | if err != nil { 168 | log.WithFields(log.Fields{ 169 | "username": user.Username, 170 | "err": err, 171 | }).Error("Failed to automatically enable Two-Factor auth") 172 | } else { 173 | autoMFA = true 174 | user.AuthTypes = otpOnly 175 | c.Locals(ContextKeyUser, user) 176 | 177 | err = r.emailer.SendMFAChangedEmail(true, user, c) 178 | if err != nil { 179 | log.WithFields(log.Fields{ 180 | "err": err, 181 | "username": user.Username, 182 | }).Error("Failed to send mfa automatically enabled email") 183 | } 184 | } 185 | } 186 | } 187 | 188 | if !autoMFA { 189 | err = r.emailer.SendOTPTokenUpdatedEmail(true, user, c) 190 | if err != nil { 191 | log.WithFields(log.Fields{ 192 | "err": err, 193 | "username": user.Username, 194 | }).Error("Failed to send otp token added email") 195 | } 196 | } 197 | 198 | return r.tokenList(c, vars) 199 | } 200 | 201 | func (r *Router) OTPTokenAdd(c *fiber.Ctx) error { 202 | client := r.userClient(c) 203 | 204 | desc := c.FormValue("desc") 205 | 206 | token := &ipa.OTPToken{ 207 | Type: ipa.TokenTypeTOTP, 208 | Algorithm: strings.ToLower(getHashAlgorithm().String()), 209 | Description: desc, 210 | NotBefore: time.Now(), 211 | } 212 | 213 | token, err := client.AddOTPToken( 214 | &ipa.OTPToken{ 215 | Type: ipa.TokenTypeTOTP, 216 | Algorithm: strings.ToLower(getHashAlgorithm().String()), 217 | Description: desc, 218 | NotBefore: time.Now(), 219 | }) 220 | 221 | if err != nil { 222 | return err 223 | } 224 | 225 | otpdata, err := QRCode(token, client.Realm()) 226 | if err != nil { 227 | return err 228 | } 229 | 230 | vars := fiber.Map{ 231 | "otpdata": otpdata, 232 | "otptoken": token, 233 | } 234 | return c.Render("otptoken-scan.html", vars) 235 | } 236 | -------------------------------------------------------------------------------- /server/password_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/viper" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestPasswordCheck(t *testing.T) { 11 | viper.Set("accounts.min_passwd_len", 8) 12 | viper.Set("accounts.min_passwd_classes", 3) 13 | 14 | assert := assert.New(t) 15 | 16 | // Too short 17 | assert.Error(checkPassword("123")) 18 | // Not enough classes 19 | assert.Error(checkPassword("123456789")) 20 | // Not enough classes 21 | assert.Error(checkPassword("test1234")) 22 | 23 | // Good 24 | assert.NoError(checkPassword("test!1234")) 25 | } 26 | -------------------------------------------------------------------------------- /server/qrcode.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "image/png" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/pquerna/otp" 11 | "github.com/spf13/viper" 12 | "github.com/ubccr/goipa" 13 | ) 14 | 15 | func QRCode(otptoken *ipa.OTPToken, realm string) (string, error) { 16 | if otptoken == nil { 17 | return "", nil 18 | } 19 | 20 | uri := otptoken.URI 21 | customIssuer := viper.GetString("accounts.otp_issuer") 22 | if customIssuer != "" { 23 | ipaUrl, err := url.Parse(otptoken.URI) 24 | if err != nil { 25 | return "", err 26 | } 27 | v := ipaUrl.Query() 28 | v.Set("issuer", customIssuer) 29 | u := url.URL{ 30 | Scheme: "otpauth", 31 | Host: strings.ToLower(otptoken.Type), 32 | Path: "/" + customIssuer + ":" + otptoken.DisplayName(), 33 | RawQuery: v.Encode(), 34 | } 35 | 36 | uri = u.String() 37 | } 38 | 39 | key, err := otp.NewKeyFromURL(uri) 40 | if err != nil { 41 | return "", err 42 | } 43 | 44 | var buf bytes.Buffer 45 | img, err := key.Image(250, 250) 46 | if err != nil { 47 | return "", err 48 | } 49 | 50 | png.Encode(&buf, img) 51 | return base64.StdEncoding.EncodeToString(buf.Bytes()), nil 52 | } 53 | -------------------------------------------------------------------------------- /server/router.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "strings" 7 | "time" 8 | 9 | "github.com/gofiber/fiber/v2" 10 | "github.com/gofiber/fiber/v2/middleware/session" 11 | hydra "github.com/ory/hydra-client-go/client" 12 | log "github.com/sirupsen/logrus" 13 | "github.com/spf13/viper" 14 | ipa "github.com/ubccr/goipa" 15 | ) 16 | 17 | type Router struct { 18 | adminClient *ipa.Client 19 | sessionStore *session.Store 20 | emailer *Emailer 21 | storage fiber.Storage 22 | 23 | // Hydra consent app support 24 | hydraClient *hydra.OryHydra 25 | hydraAdminHTTPClient *http.Client 26 | 27 | // Prometheus metrics 28 | metrics *Metrics 29 | } 30 | 31 | func NewRouter(storage fiber.Storage) (*Router, error) { 32 | r := &Router{ 33 | storage: storage, 34 | } 35 | 36 | r.adminClient = ipa.NewDefaultClient() 37 | 38 | err := r.adminClient.LoginWithKeytab(viper.GetString("site.keytab"), viper.GetString("site.ktuser")) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | r.adminClient.StickySession(false) 44 | 45 | r.sessionStore = session.New(session.Config{ 46 | Storage: storage, 47 | Expiration: time.Duration(viper.GetInt("server.session_idle_timeout")) * time.Second, 48 | CookieSameSite: "Strict", 49 | CookieSecure: viper.GetBool("server.secure_cookies"), 50 | CookieHTTPOnly: true, 51 | }) 52 | 53 | r.emailer, err = NewEmailer(storage) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | if viper.IsSet("hydra.admin_url") { 59 | adminURL, err := url.Parse(viper.GetString("hydra.admin_url")) 60 | if err != nil { 61 | log.Fatal(err) 62 | } 63 | 64 | r.hydraClient = hydra.NewHTTPClientWithConfig( 65 | nil, 66 | &hydra.TransportConfig{ 67 | Schemes: []string{adminURL.Scheme}, 68 | Host: adminURL.Host, 69 | BasePath: adminURL.Path, 70 | }) 71 | 72 | if viper.GetBool("hydra.fake_tls_termination") { 73 | r.hydraAdminHTTPClient = &http.Client{ 74 | Transport: &FakeTLSTransport{T: http.DefaultTransport}, 75 | } 76 | } else { 77 | r.hydraAdminHTTPClient = http.DefaultClient 78 | } 79 | } 80 | 81 | r.metrics = NewMetrics() 82 | 83 | return r, nil 84 | } 85 | 86 | func RemoteIP(c *fiber.Ctx) string { 87 | ips := c.IPs() 88 | if len(ips) > 0 { 89 | return strings.Join(ips, ",") 90 | } 91 | 92 | return c.IP() 93 | } 94 | 95 | func (r *Router) SetupRoutes(app *fiber.App) { 96 | // CSRF tokens stored in sessions 97 | app.Use(r.CSRF) 98 | 99 | app.Get("/", r.RequireLogin, r.Index) 100 | app.Get("/account", r.RequireLogin, r.Index) 101 | app.Get("/password", r.RequireLogin, r.Index) 102 | app.Get("/security", r.RequireLogin, r.Index) 103 | app.Get("/sshkey", r.RequireLogin, r.Index) 104 | app.Get("/otp", r.RequireLogin, r.Index) 105 | 106 | // Account Create 107 | app.Get("/signup", r.RequireNoLogin, r.AccountCreate) 108 | app.Post("/signup", r.RequireNoLogin, r.AccountCreate) 109 | 110 | // Auth 111 | app.Get("/auth/login", r.RequireNoLogin, r.Login) 112 | app.Post("/auth/login", r.RequireNoLogin, r.CheckUser) 113 | app.Post("/auth/authenticate", r.RequireNoLogin, r.Authenticate) 114 | app.Post("/auth/expiredpw", r.RequireNoLogin, r.PasswordExpired) 115 | app.Get("/auth/forgotpw", r.RequireNoLogin, r.PasswordForgot) 116 | app.Post("/auth/forgotpw", r.RequireNoLogin, r.PasswordForgot) 117 | app.Get("/auth/verify", r.RequireNoLogin, r.AccountVerifyResend) 118 | app.Post("/auth/verify", r.RequireNoLogin, r.AccountVerifyResend) 119 | app.Get("/auth/resetpw/:token", r.PasswordReset) 120 | app.Post("/auth/resetpw/:token", r.PasswordReset) 121 | app.Get("/auth/verify/:token", r.AccountVerify) 122 | app.Post("/auth/verify/:token", r.AccountVerify) 123 | app.Post("/auth/logout", r.Logout) 124 | app.Get("/auth/captcha/:id.png", r.Captcha) 125 | 126 | // Account Settings 127 | app.Get("/account/settings", r.RequireLogin, r.RequireHTMX, r.AccountSettings) 128 | app.Post("/account/settings", r.RequireLogin, r.RequireHTMX, r.AccountSettings) 129 | 130 | // Password 131 | app.Get("/password/change", r.RequireLogin, r.RequireHTMX, r.PasswordChange) 132 | app.Post("/password/change", r.RequireLogin, r.RequireHTMX, r.PasswordChange) 133 | 134 | // Security 135 | app.Get("/security/settings", r.RequireLogin, r.RequireHTMX, r.SecurityList) 136 | app.Post("/security/mfa/enable", r.RequireLogin, r.RequireHTMX, r.TwoFactorEnable) 137 | app.Post("/security/mfa/disable", r.RequireLogin, r.RequireHTMX, r.TwoFactorDisable) 138 | 139 | // SSH Keys 140 | app.Get("/sshkey/list", r.RequireLogin, r.RequireHTMX, r.SSHKeyList) 141 | app.Get("/sshkey/modal", r.RequireLogin, r.RequireHTMX, r.SSHKeyModal) 142 | app.Post("/sshkey/add", r.RequireLogin, r.RequireMFA, r.RequireHTMX, r.SSHKeyAdd) 143 | app.Post("/sshkey/remove", r.RequireLogin, r.RequireMFA, r.RequireHTMX, r.SSHKeyRemove) 144 | 145 | // OTP Tokens 146 | app.Get("/otptoken/list", r.RequireLogin, r.RequireHTMX, r.OTPTokenList) 147 | app.Get("/otptoken/modal", r.RequireLogin, r.RequireHTMX, r.OTPTokenModal) 148 | app.Post("/otptoken/add", r.RequireLogin, r.RequireHTMX, r.OTPTokenAdd) 149 | app.Post("/otptoken/verify", r.RequireLogin, r.RequireHTMX, r.OTPTokenVerify) 150 | app.Post("/otptoken/remove", r.RequireLogin, r.RequireHTMX, r.OTPTokenRemove) 151 | app.Post("/otptoken/enable", r.RequireLogin, r.RequireHTMX, r.OTPTokenEnable) 152 | app.Post("/otptoken/disable", r.RequireLogin, r.RequireHTMX, r.OTPTokenDisable) 153 | 154 | if viper.IsSet("site.logo") { 155 | app.Get("/images/logo", r.Logo) 156 | } 157 | 158 | if viper.IsSet("site.css") { 159 | app.Get("/css/styles", r.Styles) 160 | } 161 | 162 | if viper.IsSet("hydra.admin_url") { 163 | app.Get("/oauth/consent", r.ConsentGet) 164 | app.Get("/oauth/login", r.LoginOAuthGet) 165 | app.Get("/oauth/error", r.HydraError) 166 | } 167 | 168 | // Prometheus metrics 169 | if viper.GetBool("server.enable_metrics") { 170 | app.Get("/metrics", r.Metrics) 171 | } 172 | } 173 | 174 | func (r *Router) userClient(c *fiber.Ctx) *ipa.Client { 175 | return c.Locals(ContextKeyIPAClient).(*ipa.Client) 176 | } 177 | 178 | func (r *Router) username(c *fiber.Ctx) string { 179 | return c.Locals(ContextKeyUsername).(string) 180 | } 181 | 182 | func (r *Router) user(c *fiber.Ctx) *ipa.User { 183 | return c.Locals(ContextKeyUser).(*ipa.User) 184 | } 185 | 186 | func (r *Router) Index(c *fiber.Ctx) error { 187 | user := r.user(c) 188 | 189 | path := strings.TrimPrefix(c.Path(), "/") 190 | if path == "" { 191 | path = "account" 192 | } 193 | 194 | vars := fiber.Map{ 195 | "user": user, 196 | "path": path, 197 | } 198 | 199 | if path == "sshkey" { 200 | vars["keys"] = user.SSHAuthKeys 201 | } else if path == "otp" { 202 | username := r.username(c) 203 | client := r.userClient(c) 204 | 205 | tokens, err := client.FetchOTPTokens(username) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | vars["otptokens"] = tokens 211 | } 212 | 213 | return c.Render("index.html", vars) 214 | } 215 | 216 | func (r *Router) Logo(c *fiber.Ctx) error { 217 | if viper.IsSet("site.logo") { 218 | return c.SendFile(viper.GetString("site.logo")) 219 | } 220 | 221 | return c.Status(fiber.StatusNotFound).SendString("") 222 | } 223 | 224 | func (r *Router) Styles(c *fiber.Ctx) error { 225 | if viper.IsSet("site.css") { 226 | return c.SendFile(viper.GetString("site.css")) 227 | } 228 | 229 | return c.Status(fiber.StatusNotFound).SendString("") 230 | } 231 | 232 | func (r *Router) Metrics(c *fiber.Ctx) error { 233 | return r.metrics.Handler(c) 234 | } 235 | -------------------------------------------------------------------------------- /server/security.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | log "github.com/sirupsen/logrus" 6 | ) 7 | 8 | func (r *Router) securityList(c *fiber.Ctx, vars fiber.Map) error { 9 | vars["user"] = r.user(c) 10 | return c.Render("security.html", vars) 11 | } 12 | 13 | func (r *Router) SecurityList(c *fiber.Ctx) error { 14 | return r.securityList(c, fiber.Map{}) 15 | } 16 | 17 | func (r *Router) TwoFactorDisable(c *fiber.Ctx) error { 18 | vars := fiber.Map{} 19 | user := r.user(c) 20 | 21 | err := r.adminClient.SetAuthTypes(user.Username, nil) 22 | if err != nil { 23 | log.WithFields(log.Fields{ 24 | "username": user.Username, 25 | "err": err, 26 | }).Error("Failed to disable Two-Factor auth") 27 | vars["message"] = "Failed to disable Two-Factor authentication" 28 | } 29 | 30 | user.AuthTypes = nil 31 | c.Locals(ContextKeyUser, user) 32 | 33 | err = r.emailer.SendMFAChangedEmail(false, user, c) 34 | if err != nil { 35 | log.WithFields(log.Fields{ 36 | "err": err, 37 | "username": user.Username, 38 | }).Error("Failed to send mfa disabled email") 39 | } 40 | 41 | return r.securityList(c, vars) 42 | } 43 | 44 | func (r *Router) TwoFactorEnable(c *fiber.Ctx) error { 45 | client := r.userClient(c) 46 | vars := fiber.Map{} 47 | user := r.user(c) 48 | 49 | tokens, err := client.FetchOTPTokens(user.Username) 50 | if err != nil { 51 | log.WithFields(log.Fields{ 52 | "username": user.Username, 53 | "err": err, 54 | }).Error("Failed to check otp tokens") 55 | vars["message"] = "Failed to enable Two-Factor authentication" 56 | return r.securityList(c, vars) 57 | } 58 | 59 | if len(tokens) == 0 { 60 | vars["message"] = "You must add an OTP token first before enabling Two-Factor authentication" 61 | return r.securityList(c, vars) 62 | } 63 | 64 | otpOnly := []string{"otp"} 65 | err = r.adminClient.SetAuthTypes(user.Username, otpOnly) 66 | if err != nil { 67 | log.WithFields(log.Fields{ 68 | "username": user.Username, 69 | "err": err, 70 | }).Error("Failed to enable Two-Factor auth") 71 | vars["message"] = "Failed to enable Two-Factor authentication" 72 | } 73 | 74 | user.AuthTypes = otpOnly 75 | c.Locals(ContextKeyUser, user) 76 | 77 | err = r.emailer.SendMFAChangedEmail(true, user, c) 78 | if err != nil { 79 | log.WithFields(log.Fields{ 80 | "err": err, 81 | "username": user.Username, 82 | }).Error("Failed to send mfa enabled email") 83 | } 84 | 85 | return r.securityList(c, vars) 86 | } 87 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "errors" 7 | "io/fs" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/gofiber/fiber/v2" 14 | "github.com/gofiber/fiber/v2/middleware/favicon" 15 | "github.com/gofiber/fiber/v2/middleware/filesystem" 16 | "github.com/gofiber/fiber/v2/middleware/limiter" 17 | frecover "github.com/gofiber/fiber/v2/middleware/recover" 18 | "github.com/gofiber/storage/memory/v2" 19 | "github.com/gofiber/storage/redis/v3" 20 | "github.com/gofiber/storage/sqlite3/v2" 21 | log "github.com/sirupsen/logrus" 22 | "github.com/spf13/viper" 23 | ) 24 | 25 | const ( 26 | DefaultPort = 80 27 | ) 28 | 29 | //go:embed templates/static 30 | var staticFS embed.FS 31 | 32 | type Server struct { 33 | ListenAddress string 34 | Scheme string 35 | KeyFile string 36 | CertFile string 37 | app *fiber.App 38 | } 39 | 40 | func SetDefaults() { 41 | viper.SetDefault("site.name", "Acme Widgets") 42 | viper.SetDefault("site.ktuser", "mokeyapp") 43 | viper.SetDefault("accounts.hide_invalid_username_error", false) 44 | viper.SetDefault("accounts.default_homedir", "/home") 45 | viper.SetDefault("accounts.default_shell", "/bin/bash") 46 | viper.SetDefault("accounts.min_passwd_len", 8) 47 | viper.SetDefault("accounts.min_passwd_classes", 2) 48 | viper.SetDefault("accounts.otp_hash_algorithm", "sha1") 49 | viper.SetDefault("accounts.username_from_email", false) 50 | viper.SetDefault("accounts.require_mfa", false) 51 | viper.SetDefault("accounts.require_admin_verify", false) 52 | viper.SetDefault("email.token_max_age", 3600) 53 | viper.SetDefault("email.smtp_host", "localhost") 54 | viper.SetDefault("email.smtp_port", 25) 55 | viper.SetDefault("email.smtp_tls", "off") 56 | viper.SetDefault("email.from", "support@example.com") 57 | viper.SetDefault("server.secure_cookies", true) 58 | viper.SetDefault("server.session_idle_timeout", 900) 59 | viper.SetDefault("server.listen", "0.0.0.0:8866") 60 | viper.SetDefault("server.read_timeout", 5) 61 | viper.SetDefault("server.write_timeout", 5) 62 | viper.SetDefault("server.idle_timeout", 120) 63 | viper.SetDefault("server.rate_limit_expiration", 3600) 64 | viper.SetDefault("server.rate_limit_max", 10) 65 | viper.SetDefault("storage.driver", "memory") 66 | } 67 | 68 | func NewServer(address string) (*Server, error) { 69 | s := &Server{} 70 | s.ListenAddress = address 71 | 72 | app, err := newFiber() 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | s.app = app 78 | 79 | return s, nil 80 | } 81 | 82 | func getAssetsFS() http.FileSystem { 83 | staticLocalPath := viper.GetString("site.static_assets_dir") 84 | if staticLocalPath != "" { 85 | log.Debugf("Using local static assets dir: %s", staticLocalPath) 86 | return http.FS(os.DirFS(staticLocalPath)) 87 | } 88 | 89 | fsys, err := fs.Sub(staticFS, "templates/static") 90 | if err != nil { 91 | log.Fatal(err) 92 | } 93 | 94 | return http.FS(fsys) 95 | } 96 | 97 | func recoverInvalidStorage() { 98 | if r := recover(); r != nil { 99 | log.Errorf("Failed initialize storage driver %s: %s", viper.GetString("storage.driver"), r) 100 | } 101 | } 102 | 103 | func newStorage() fiber.Storage { 104 | var storage fiber.Storage 105 | 106 | if viper.IsSet("storage.sqlite3.dbpath") && viper.GetString("storage.driver") == "memory" { 107 | viper.Set("storage.driver", "sqlite3") 108 | } 109 | 110 | defer recoverInvalidStorage() 111 | switch viper.GetString("storage.driver") { 112 | case "sqlite3": 113 | storage = sqlite3.New(sqlite3.Config{ 114 | Database: viper.GetString("storage.sqlite3.dbpath"), 115 | Table: "mokey_data", 116 | }) 117 | case "redis": 118 | storage = redis.New(redis.Config{ 119 | URL: viper.GetString("storage.redis.url"), 120 | Reset: false, 121 | }) 122 | 123 | default: 124 | storage = memory.New() 125 | } 126 | 127 | return storage 128 | } 129 | 130 | func newFiber() (*fiber.App, error) { 131 | engine, err := NewTemplateRenderer() 132 | if err != nil { 133 | log.Fatal(err) 134 | } 135 | 136 | storage := newStorage() 137 | if storage == nil { 138 | return nil, errors.New("Failed to open mokey storage database") 139 | } 140 | 141 | app := fiber.New(fiber.Config{ 142 | Prefork: false, 143 | CaseSensitive: true, 144 | StrictRouting: true, 145 | ReadTimeout: time.Duration(viper.GetInt("server.read_timeout")) * time.Second, 146 | WriteTimeout: time.Duration(viper.GetInt("server.write_timeout")) * time.Second, 147 | IdleTimeout: time.Duration(viper.GetInt("server.idle_timeout")) * time.Second, 148 | AppName: "mokey", 149 | DisableStartupMessage: true, 150 | PassLocalsToViews: true, 151 | ErrorHandler: HTTPErrorHandler, 152 | Views: engine, 153 | }) 154 | 155 | app.Use(frecover.New()) 156 | app.Use(SecureHeaders) 157 | 158 | app.Use(limiter.New(limiter.Config{ 159 | Max: viper.GetInt("server.rate_limit_max"), 160 | Expiration: time.Duration(viper.GetInt("server.rate_limit_expiration")) * time.Second, 161 | SkipSuccessfulRequests: true, 162 | Storage: storage, 163 | LimitReached: LimitReachedHandler, 164 | KeyGenerator: func(c *fiber.Ctx) string { 165 | ips := c.IPs() 166 | if len(ips) > 0 { 167 | return ips[0] 168 | } 169 | 170 | return c.IP() 171 | }, 172 | Next: func(c *fiber.Ctx) bool { 173 | if c.Method() != fiber.MethodPost { 174 | return true 175 | } 176 | 177 | if c.Path() == "/signup" { 178 | return false 179 | } 180 | 181 | if strings.HasPrefix(c.Path(), "/auth") { 182 | return false 183 | } 184 | 185 | return true 186 | }, 187 | })) 188 | 189 | router, err := NewRouter(storage) 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | router.SetupRoutes(app) 195 | 196 | assetsFS := getAssetsFS() 197 | app.Use("/static", filesystem.New(filesystem.Config{ 198 | Root: assetsFS, 199 | Browse: false, 200 | MaxAge: 900, 201 | })) 202 | 203 | if viper.IsSet("site.favicon") { 204 | app.Use(favicon.New(favicon.Config{ 205 | File: viper.GetString("site.favicon"), 206 | })) 207 | } else { 208 | app.Use(favicon.New(favicon.Config{ 209 | File: "images/favicon.ico", 210 | FileSystem: assetsFS, 211 | })) 212 | } 213 | 214 | // This must be last 215 | app.Use(NotFoundHandler) 216 | 217 | return app, nil 218 | } 219 | 220 | func (s *Server) Serve() error { 221 | if s.CertFile != "" && s.KeyFile != "" { 222 | s.Scheme = "https" 223 | log.Infof("Listening on %s://%s", s.Scheme, s.ListenAddress) 224 | if err := s.app.ListenTLS(s.ListenAddress, s.CertFile, s.KeyFile); err != nil { 225 | return err 226 | } 227 | } 228 | 229 | s.Scheme = "http" 230 | log.Infof("Listening on %s://%s", s.Scheme, s.ListenAddress) 231 | if err := s.app.Listen(s.ListenAddress); err != nil { 232 | return err 233 | } 234 | 235 | return nil 236 | } 237 | 238 | func (s *Server) Shutdown(ctx context.Context) error { 239 | if s.app == nil { 240 | return nil 241 | } 242 | 243 | return s.app.Shutdown() 244 | } 245 | -------------------------------------------------------------------------------- /server/session.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/gofiber/fiber/v2/middleware/session" 6 | log "github.com/sirupsen/logrus" 7 | ) 8 | 9 | func (r *Router) session(c *fiber.Ctx) (*session.Session, error) { 10 | sess, err := r.sessionStore.Get(c) 11 | if err != nil { 12 | log.WithFields(log.Fields{ 13 | "path": c.Path(), 14 | "err": err, 15 | }).Error("Failed to fetch session from storage") 16 | return nil, err 17 | } 18 | 19 | return sess, nil 20 | } 21 | 22 | func (r *Router) sessionSave(c *fiber.Ctx, sess *session.Session) error { 23 | if err := sess.Save(); err != nil { 24 | log.WithFields(log.Fields{ 25 | "path": c.Path(), 26 | "err": err, 27 | }).Error("Failed to save session to storage") 28 | 29 | return err 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /server/sshpubkey.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | log "github.com/sirupsen/logrus" 6 | ipa "github.com/ubccr/goipa" 7 | ) 8 | 9 | func (r *Router) SSHKeyList(c *fiber.Ctx) error { 10 | user := r.user(c) 11 | vars := fiber.Map{ 12 | "user": user, 13 | } 14 | return c.Render("sshkey-list.html", vars) 15 | } 16 | 17 | func (r *Router) SSHKeyModal(c *fiber.Ctx) error { 18 | vars := fiber.Map{} 19 | return c.Render("sshkey-new.html", vars) 20 | } 21 | 22 | func (r *Router) SSHKeyAdd(c *fiber.Ctx) error { 23 | user := r.user(c) 24 | 25 | title := c.FormValue("title") 26 | key := c.FormValue("key") 27 | 28 | if key == "" { 29 | return c.Status(fiber.StatusBadRequest).SendString("Please provide an ssh key") 30 | } 31 | 32 | authKey, err := ipa.NewSSHAuthorizedKey(key) 33 | if err != nil { 34 | log.WithFields(log.Fields{ 35 | "username": user.Username, 36 | "err": err, 37 | }).Error("Failed to add new ssh key") 38 | return c.Status(fiber.StatusBadRequest).SendString("Invalid ssh key") 39 | } 40 | 41 | if title != "" { 42 | // TODO validate title 43 | authKey.Comment = title 44 | } 45 | 46 | user.AddSSHAuthorizedKey(authKey) 47 | 48 | user, err = r.adminClient.UserMod(user) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | c.Locals(ContextKeyUser, user) 54 | 55 | err = r.emailer.SendSSHKeyUpdatedEmail(true, user, c) 56 | if err != nil { 57 | log.WithFields(log.Fields{ 58 | "err": err, 59 | "username": user.Username, 60 | }).Error("Failed to send sshkey added email") 61 | } 62 | 63 | return r.SSHKeyList(c) 64 | } 65 | 66 | func (r *Router) SSHKeyRemove(c *fiber.Ctx) error { 67 | fp := c.FormValue("fp") 68 | user := r.user(c) 69 | 70 | user.RemoveSSHAuthorizedKey(fp) 71 | 72 | var err error 73 | user, err = r.adminClient.UserMod(user) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | c.Locals(ContextKeyUser, user) 79 | 80 | err = r.emailer.SendSSHKeyUpdatedEmail(false, user, c) 81 | if err != nil { 82 | log.WithFields(log.Fields{ 83 | "err": err, 84 | "username": user.Username, 85 | }).Error("Failed to send sshkey removed email") 86 | } 87 | 88 | return r.SSHKeyList(c) 89 | } 90 | -------------------------------------------------------------------------------- /server/template.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | "io" 7 | "path/filepath" 8 | "sort" 9 | "strings" 10 | "time" 11 | 12 | "github.com/dustin/go-humanize" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | //go:embed templates 17 | var templateFiles embed.FS 18 | 19 | // Template functions 20 | var funcMap = template.FuncMap{ 21 | "SplitSSHFP": SplitSSHFP, 22 | "TimeAgo": TimeAgo, 23 | "ConfigValueString": ConfigValueString, 24 | "ConfigValueBool": ConfigValueBool, 25 | "AllowedDomains": AllowedDomains, 26 | "BreakNewlines": BreakNewlines, 27 | } 28 | 29 | type TemplateRenderer struct { 30 | templates *template.Template 31 | } 32 | 33 | func NewTemplateRenderer() (*TemplateRenderer, error) { 34 | 35 | tmpl := template.New("") 36 | tmpl.Funcs(funcMap) 37 | tmpl, err := tmpl.ParseFS(templateFiles, "templates/*.html") 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | if viper.IsSet("site.templates_dir") { 43 | localTemplatePath := filepath.Join(viper.GetString("site.templates_dir"), "*.html") 44 | localTemplates, err := filepath.Glob(localTemplatePath) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | if len(localTemplates) > 0 { 50 | tmpl, err = tmpl.ParseGlob(localTemplatePath) 51 | if err != nil { 52 | return nil, err 53 | } 54 | } 55 | } 56 | 57 | tmpl.Funcs(funcMap) 58 | 59 | t := &TemplateRenderer{ 60 | templates: tmpl, 61 | } 62 | 63 | return t, nil 64 | } 65 | 66 | func (t *TemplateRenderer) Load() error { 67 | return nil 68 | } 69 | 70 | func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, layouts ...string) error { 71 | return t.templates.ExecuteTemplate(w, name, data) 72 | } 73 | 74 | func AllowedDomains() string { 75 | allowedDomains := viper.GetStringMapString("accounts.allowed_domains") 76 | 77 | i := 0 78 | domains := make([]string, len(allowedDomains)) 79 | for d := range allowedDomains { 80 | domains[i] = d 81 | i++ 82 | } 83 | 84 | sort.Strings(domains) 85 | 86 | return strings.Join(domains, ", ") 87 | } 88 | 89 | func ConfigValueString(key string) string { 90 | return viper.GetString(key) 91 | } 92 | 93 | func ConfigValueBool(key string) bool { 94 | return viper.GetBool(key) 95 | } 96 | 97 | func TimeAgo(t time.Time) string { 98 | return humanize.Time(t) 99 | } 100 | 101 | func SplitSSHFP(fp string) []string { 102 | if fp == "" { 103 | return []string{"", "", ""} 104 | } 105 | 106 | parts := strings.Split(fp, " ") 107 | if len(parts) == 1 { 108 | return []string{parts[0], "", ""} 109 | } 110 | 111 | if len(parts) == 2 { 112 | return []string{parts[0], parts[1], ""} 113 | } 114 | 115 | parts[2] = strings.TrimLeft(parts[2], "(") 116 | parts[2] = strings.TrimRight(parts[2], ")") 117 | return parts 118 | } 119 | 120 | func BreakNewlines(s string) template.HTML { 121 | return template.HTML(strings.Replace(template.HTMLEscapeString(s), "\n", "
", -1)) 122 | } 123 | -------------------------------------------------------------------------------- /server/templates/401.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 |
3 |
4 | 5 | 8 | 9 | 12 | 13 | 14 |
15 |
16 | {{ template "footer.html" . }} 17 | -------------------------------------------------------------------------------- /server/templates/403-partial.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /server/templates/403.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 |
3 |
4 | 5 | 8 | 9 | 12 | 13 | 14 |
15 |
16 | {{ template "footer.html" . }} 17 | -------------------------------------------------------------------------------- /server/templates/404-partial.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /server/templates/404.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 |
3 |
4 | 5 | 8 | 9 | 12 | 13 | 14 |
15 |
16 | {{ template "footer.html" . }} 17 | -------------------------------------------------------------------------------- /server/templates/500-partial.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /server/templates/500.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 |
3 |
4 | 5 | 8 | 9 | 12 | 13 | 14 |
15 |
16 | {{ template "footer.html" . }} 17 | -------------------------------------------------------------------------------- /server/templates/account-verify-forgot-success.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /server/templates/account-verify-forgot.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 |
3 | 5 |
6 | 37 | 38 |
39 |
40 | 41 | {{ with $.captchaID }} 42 | 63 | {{ end }} 64 | {{ template "footer.html" . }} 65 | -------------------------------------------------------------------------------- /server/templates/account.html: -------------------------------------------------------------------------------- 1 | {{ if and (not $.user.OTPOnly) (ConfigValueBool "accounts.require_mfa") }} 2 | 5 | {{ end }} 6 | {{ with $.message }} 7 | 11 | {{ end }} 12 | {{ with $.success }} 13 | 17 | {{ end }} 18 | 20 |

Account Settings

21 |
22 |
23 |
24 |
25 | 26 | 27 |
28 |
29 |
30 |
31 | 32 | 33 |
34 |
35 |
36 |
37 | 38 | 39 |
40 |
41 |
42 |
43 | 44 | 45 |
46 |
47 |
48 |
49 | 50 | 51 |
52 |
53 |
54 |
55 | 56 | 57 |
58 |
59 |
60 |
61 | 62 | 63 |
64 |
65 |
66 |
67 | 68 | 69 |
70 |
71 |
72 |
73 | 74 |
75 | {{ range $g := .user.Groups }} 76 | {{ $g }} 77 | {{ else }} 78 |   79 | {{ end }} 80 |
81 |
82 |
83 |
84 |
85 | 91 | 92 |
93 | -------------------------------------------------------------------------------- /server/templates/email/account-updated.txt: -------------------------------------------------------------------------------- 1 | [{{ $.site_name }}] ( {{ $.homepage }} ) 2 | 3 | **************** 4 | Hi {{ $.user.First }}, 5 | **************** 6 | 7 | You recently updated your {{ $.site_name }} account. For reference, here's what changed: 8 | 9 | {{ $.event }} 10 | 11 | For security, this change was made from a {{ $.os }} device using {{ $.browser }}. If you did not make this change, please immediately contact support ( {{ $.contact }} ) or check out our help documentation ( {{ $.help_url }} ) if you have questions. 12 | 13 | Thanks, 14 | The [{{ $.site_name }}] team 15 | 16 | {{ $.sig }} 17 | -------------------------------------------------------------------------------- /server/templates/email/account-verify.txt: -------------------------------------------------------------------------------- 1 | [{{ $.site_name }}] ( {{ $.homepage }} ) 2 | 3 | **************** 4 | Hi {{ $.user.First }}, 5 | **************** 6 | 7 | You recently created an account at {{ $.site_name }} and you MUST verify your email before using your account. Use the link below to verify your email address. This link is only valid for the next {{ $.link_expires }}. 8 | 9 | Verify your account: {{ $.link }} 10 | 11 | For reference, here's your login information: 12 | 13 | Login Page: {{ $.base_url }} 14 | 15 | Username: {{ $.user.Username }} 16 | 17 | For security, this request was received from a {{ $.os }} device using {{ $.browser }}. If you did not request an account, please ignore this email and contact support ( {{ $.contact }} ) or check out our help documentation ( {{ $.help_url }} ) if you have questions. 18 | 19 | Thanks, 20 | The [{{ $.site_name }}] team 21 | 22 | If you're having trouble with the link above, copy and paste the URL into your web browser. 23 | 24 | {{ $.sig }} 25 | -------------------------------------------------------------------------------- /server/templates/email/password-reset.txt: -------------------------------------------------------------------------------- 1 | [{{ $.site_name }}] ( {{ $.homepage }} ) 2 | 3 | **************** 4 | Hi {{ $.user.First }}, 5 | **************** 6 | 7 | You recently requested to reset your password for your [{{ $.site_name }}] account. Use the link below to reset it. This password reset is only valid for the next {{ $.link_expires }}. 8 | 9 | Reset your password: {{ $.link }} 10 | 11 | For security, this request was received from a {{ $.os }} device using {{ $.browser }}. If you did not request a password reset, please ignore this email and contact support ( {{ $.contact }} ) or check out our help documenation ( {{ $.help_url }} ) if you have questions. 12 | 13 | Thanks, 14 | The [{{ $.site_name }}] team 15 | 16 | If you're having trouble with the link above, copy and paste the URL into your web browser. 17 | 18 | {{ $.sig }} 19 | -------------------------------------------------------------------------------- /server/templates/email/welcome.txt: -------------------------------------------------------------------------------- 1 | Thanks for creating an account at [{{ $.site_name }}]. We've pulled together some information and resources to help you get started. 2 | 3 | [{{ $.site_name }}] ( {{$.homepage}} ) 4 | 5 | ****************** 6 | Welcome, {{$.user.First}}! 7 | ****************** 8 | 9 | Thanks for creating an account at [{{$.site_name}}]. We're glad to have you on board. {{ if ConfigValueBool "accounts.require_mfa" }}You MUST enabled Two-Factor authentication on your account. Please login and create a new OTP token using your authenticator app.{{ end }} To get the most out of [{{$.site_name}}], check out our getting started guide here: 10 | 11 | Getting started ( {{ $.getting_started_url }} ) 12 | 13 | For reference, here's your login information: 14 | 15 | Login Page: {{$.base_url}} 16 | 17 | Username: {{$.user.Username}} 18 | 19 | If you have any questions, feel free to email support ( {{ $.contact }} ). Also check out our help documentation ( {{ $.help_url }} ) if you have questions. 20 | 21 | Thanks, 22 | The [{{ $.site_name }}] team 23 | 24 | {{ $.sig }} 25 | -------------------------------------------------------------------------------- /server/templates/footer.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |

5 | {{ with ConfigValueString "site.homepage" }} 6 | {{ ConfigValueString "site.name" }} 7 | {{ else }} 8 | Powered by mokey 9 | {{ end }} 10 |

11 | 12 | {{ with ConfigValueString "site.help_url" }} 13 | 16 | {{ end }} 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /server/templates/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ ConfigValueString "site.name" }} - Identity Management 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{ with ConfigValueString "site.css" }} 15 | 16 | {{ end }} 17 | 18 | 19 | 26 | -------------------------------------------------------------------------------- /server/templates/index.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 | 3 |
4 |
5 |
6 | 40 |
41 | {{ if eq $.path "account" }} 42 |
43 | {{ template "account.html" . }} 44 |
45 | {{ else if eq $.path "password" }} 46 |
47 | {{ template "password.html" . }} 48 |
49 | {{ else if eq $.path "security" }} 50 |
51 | {{ template "security.html" . }} 52 |
53 | {{ else if eq $.path "sshkey" }} 54 |
55 | {{ template "sshkey-list.html" . }} 56 |
57 | {{ else if eq $.path "otp" }} 58 |
59 | {{ template "otptoken-list.html" . }} 60 |
61 | {{ end }} 62 |
63 |
64 |
65 |
66 | 67 | {{ template "footer.html" . }} 68 | -------------------------------------------------------------------------------- /server/templates/login-form.html: -------------------------------------------------------------------------------- 1 | 38 | -------------------------------------------------------------------------------- /server/templates/login-password-expired.html: -------------------------------------------------------------------------------- 1 | 41 | -------------------------------------------------------------------------------- /server/templates/login.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 | 3 |
4 | 6 |
7 | 30 | 31 |
32 |
33 | 34 | {{ template "footer.html" . }} 35 | -------------------------------------------------------------------------------- /server/templates/otptoken-list.html: -------------------------------------------------------------------------------- 1 | {{ if and (not $.user.OTPOnly) (ConfigValueBool "accounts.require_mfa") (not $.otptokens) }} 2 | 5 | {{ else if and (not $.user.OTPOnly) (ConfigValueBool "accounts.require_mfa") }} 6 | 9 | {{ end }} 10 | {{ with $.message }} 11 | 15 | {{ end }} 16 | 18 |
19 |
20 |

OTP Tokens

21 | 29 |
30 | {{ range $i, $tok := $.otptokens }} 31 |
32 |
33 |
34 | 35 | 36 | {{ $tok.Type }} 37 | 38 |
39 |
40 | {{ $tok.DisplayName }} 41 | 42 | {{ $tok.Description }} 43 | 44 | {{ if not $tok.NotBefore.IsZero }}Added on {{ $tok.NotBefore.Format "Jan 02, 2006" }}{{ end }} 45 |

46 | {{ if $tok.Enabled }} 47 | 65 | {{ else }} 66 | 86 | {{ end }} 87 | 106 |

107 |
108 |
109 | 110 |
111 | {{ else }} 112 |

No OTP tokens found

113 | {{ end }} 114 | -------------------------------------------------------------------------------- /server/templates/otptoken-new.html: -------------------------------------------------------------------------------- 1 | 2 | 48 | -------------------------------------------------------------------------------- /server/templates/otptoken-scan.html: -------------------------------------------------------------------------------- 1 | 64 | -------------------------------------------------------------------------------- /server/templates/partials/otp.html: -------------------------------------------------------------------------------- 1 |

test no layout here

2 | -------------------------------------------------------------------------------- /server/templates/password-forgot-success.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /server/templates/password-forgot.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 |
3 | 5 |
6 | 37 | 38 |
39 |
40 | 41 | {{ with $.captchaID }} 42 | 63 | {{ end }} 64 | {{ template "footer.html" . }} 65 | -------------------------------------------------------------------------------- /server/templates/password-reset-success.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /server/templates/password-reset.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 |
3 | 5 |
6 | 37 | 38 |
39 |
40 | 41 | 48 | {{ template "footer.html" . }} 49 | -------------------------------------------------------------------------------- /server/templates/password.html: -------------------------------------------------------------------------------- 1 | {{ with $.message }} 2 | 6 | {{ end }} 7 | {{ with $.success }} 8 | 12 | {{ end }} 13 | 15 |

Change Password

16 |
17 |
18 |
19 |
20 | 21 | 22 |
23 |
24 |
25 |
26 |
27 |
28 | 29 | 30 |
31 |
32 |
33 |
34 | 35 | 36 |
37 |
38 |
39 | {{ if $.user.OTPOnly }} 40 |
41 |
42 |
43 | 44 | 45 |
Enter the six-digit auth code from your mobile app
46 |
47 |
48 |
49 | {{ end }} 50 |
51 | 57 | 58 |
59 | -------------------------------------------------------------------------------- /server/templates/security.html: -------------------------------------------------------------------------------- 1 | {{ if and (not $.user.OTPOnly) (ConfigValueBool "accounts.require_mfa") }} 2 | 5 | {{ end }} 6 | {{ with $.message }} 7 | 11 | {{ end }} 12 | 14 |

Security Settings

15 |
16 |
17 | Authentication Methods 18 |
19 |
    20 |
  • 21 |
    22 |
    Two-factor authentication
    23 | {{ if $.user.OTPOnly }} 24 | 42 | {{ else }} 43 | 63 | {{ end }} 64 |
    65 |
  • 66 |
67 |
68 | -------------------------------------------------------------------------------- /server/templates/signup-success.html: -------------------------------------------------------------------------------- 1 | 23 | -------------------------------------------------------------------------------- /server/templates/signup.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 |
3 | 5 |
6 | 72 | 73 |
74 |
75 | 76 | 83 | {{ with $.captchaID }} 84 | 105 | {{ end }} 106 | {{ template "footer.html" . }} 107 | -------------------------------------------------------------------------------- /server/templates/sshkey-list.html: -------------------------------------------------------------------------------- 1 | {{ if and (not $.user.OTPOnly) (ConfigValueBool "accounts.require_mfa") }} 2 | 5 | {{ end }} 6 | {{ with $.message }} 7 | 11 | {{ end }} 12 | 14 |
15 | 16 |
17 |

SSH Keys

18 | 26 |
27 | {{ range $i, $key := $.user.SSHAuthKeys }} 28 |
29 |
30 |
31 | 32 | 33 | SSH 34 | 35 |
36 |
37 | {{ $key.Comment }} 38 | 39 | {{ $key.Fingerprint }} 40 | 41 | 42 | Type: {{ slice $key.PublicKey.Type 4 }} 43 | 44 |

45 | 64 |

65 |
66 |
67 |
68 | {{ else }} 69 |

No ssh keys uploaded

70 | {{ end }} 71 | -------------------------------------------------------------------------------- /server/templates/sshkey-new.html: -------------------------------------------------------------------------------- 1 | 2 | 44 | -------------------------------------------------------------------------------- /server/templates/static/css/fonts.css: -------------------------------------------------------------------------------- 1 | /* roboto-300 - latin */ 2 | @font-face { 3 | font-family: 'Roboto'; 4 | font-style: normal; 5 | font-weight: 300; 6 | src: local(''), 7 | url('../webfonts/roboto-v29-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 8 | url('../webfonts/roboto-v29-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 9 | } 10 | 11 | /* roboto-300italic - latin */ 12 | @font-face { 13 | font-family: 'Roboto'; 14 | font-style: italic; 15 | font-weight: 300; 16 | src: local(''), 17 | url('../webfonts/roboto-v29-latin-300italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 18 | url('../webfonts/roboto-v29-latin-300italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 19 | } 20 | 21 | /* roboto-regular - latin */ 22 | @font-face { 23 | font-family: 'Roboto'; 24 | font-style: normal; 25 | font-weight: 400; 26 | src: local(''), 27 | url('../webfonts/roboto-v29-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 28 | url('../webfonts/roboto-v29-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 29 | } 30 | 31 | /* roboto-italic - latin */ 32 | @font-face { 33 | font-family: 'Roboto'; 34 | font-style: italic; 35 | font-weight: 400; 36 | src: local(''), 37 | url('../webfonts/roboto-v29-latin-italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 38 | url('../webfonts/roboto-v29-latin-italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 39 | } 40 | 41 | /* roboto-500 - latin */ 42 | @font-face { 43 | font-family: 'Roboto'; 44 | font-style: normal; 45 | font-weight: 500; 46 | src: local(''), 47 | url('../webfonts/roboto-v29-latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 48 | url('../webfonts/roboto-v29-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 49 | } 50 | 51 | /* roboto-500italic - latin */ 52 | @font-face { 53 | font-family: 'Roboto'; 54 | font-style: italic; 55 | font-weight: 500; 56 | src: local(''), 57 | url('../webfonts/roboto-v29-latin-500italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 58 | url('../webfonts/roboto-v29-latin-500italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 59 | } 60 | 61 | /* roboto-700 - latin */ 62 | @font-face { 63 | font-family: 'Roboto'; 64 | font-style: normal; 65 | font-weight: 700; 66 | src: local(''), 67 | url('../webfonts/roboto-v29-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 68 | url('../webfonts/roboto-v29-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 69 | } 70 | 71 | /* roboto-700italic - latin */ 72 | @font-face { 73 | font-family: 'Roboto'; 74 | font-style: italic; 75 | font-weight: 700; 76 | src: local(''), 77 | url('../webfonts/roboto-v29-latin-700italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */ 78 | url('../webfonts/roboto-v29-latin-700italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 79 | } 80 | -------------------------------------------------------------------------------- /server/templates/static/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #f9f9f9; 3 | font-family: "Roboto", sans-serif; 4 | } 5 | 6 | .main-content { 7 | padding-top: 100px; 8 | padding-bottom: 100px; 9 | } 10 | 11 | .shadow { 12 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1) !important; 13 | } 14 | 15 | .login-card { 16 | max-width: 500px; 17 | box-shadow: 0 2px 30px rgba(0, 0, 0, 0.1); 18 | } 19 | .login-card .login-body .login-body-wrapper { 20 | max-width: 400px; 21 | } 22 | 23 | .login-failed { 24 | max-width: 500px; 25 | } 26 | 27 | .navbar-inverse { 28 | background-color: #0055bb; 29 | border-color: #ccc; 30 | } 31 | 32 | .navbar-inverse .navbar-text { 33 | color: #fff; 34 | } 35 | 36 | .navbar-inverse .navbar-text > a { 37 | color: #fff; 38 | } 39 | 40 | .footer .text-muted > a { 41 | color: #fff; 42 | } 43 | 44 | .footer { 45 | position: absolute; 46 | bottom: 0; 47 | width: 100%; 48 | /* Set the fixed height of the footer here */ 49 | height: 60px; 50 | background-color: #0055bb; 51 | } 52 | 53 | 54 | .shadow { 55 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1) !important; 56 | } 57 | 58 | .profile-tab-nav { 59 | min-width: 250px; 60 | } 61 | 62 | .tab-content { 63 | flex: 1; 64 | } 65 | 66 | .form-group { 67 | margin-bottom: 1.5rem; 68 | } 69 | 70 | .nav-pills a.nav-link { 71 | padding: 15px 20px; 72 | border-bottom: 1px solid #ddd; 73 | border-radius: 0; 74 | color: #333; 75 | } 76 | .nav-pills a.nav-link i { 77 | width: 20px; 78 | } 79 | 80 | .img-circle img { 81 | height: 100px; 82 | width: 100px; 83 | border-radius: 100%; 84 | border: 5px solid #fff; 85 | } 86 | 87 | .btn-alternate-hide, 88 | .btn-alternate-show { 89 | width: 80px; 90 | } 91 | .btn-alternate-show { 92 | display: none; 93 | } 94 | .btn-alternate-hide:hover + .btn-alternate-show { 95 | display: block; 96 | } 97 | .btn-alternate-show:hover { 98 | display: block; 99 | } 100 | -------------------------------------------------------------------------------- /server/templates/static/images/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/images/android-chrome-192x192.png -------------------------------------------------------------------------------- /server/templates/static/images/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/images/android-chrome-512x512.png -------------------------------------------------------------------------------- /server/templates/static/images/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/images/apple-touch-icon.png -------------------------------------------------------------------------------- /server/templates/static/images/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/images/favicon-16x16.png -------------------------------------------------------------------------------- /server/templates/static/images/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/images/favicon-32x32.png -------------------------------------------------------------------------------- /server/templates/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/images/favicon.ico -------------------------------------------------------------------------------- /server/templates/static/images/scan-qr-code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/images/scan-qr-code.png -------------------------------------------------------------------------------- /server/templates/static/images/user-circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/images/user-circle.png -------------------------------------------------------------------------------- /server/templates/static/js/site.js: -------------------------------------------------------------------------------- 1 | document.body.addEventListener('htmx:afterRequest', function (evt) { 2 | const targetError = evt.target.attributes.getNamedItem('hx-target-error') 3 | if (evt.detail.failed && targetError) { 4 | msg = "Something bad happened. Please contact site admin"; 5 | if(evt.detail.xhr.status == 400 || evt.detail.xhr.status == 401 || evt.detail.xhr.status == 429) { 6 | msg = evt.detail.xhr.responseText; 7 | } 8 | 9 | errAlert = document.getElementById(targetError.value) 10 | errAlert.innerHTML = msg; 11 | errAlert.style.display = "block"; 12 | window.scrollTo(0, 0); 13 | if(targetError.value.indexOf('dismiss') !== -1) { 14 | setTimeout(() => { 15 | errAlert.style.display = "none"; 16 | }, 3000); 17 | } 18 | } 19 | }); 20 | document.body.addEventListener('htmx:beforeRequest', function (evt) { 21 | const targetError = evt.target.attributes.getNamedItem('hx-target-error') 22 | if (targetError) { 23 | document.getElementById(targetError.value).style.display = "none"; 24 | } 25 | }); 26 | 27 | function closeModal(ele) { 28 | var container = document.getElementById(ele) 29 | var backdrop = document.getElementById("modal-backdrop") 30 | var modal = document.getElementById("modal") 31 | 32 | modal.classList.remove("show") 33 | backdrop.classList.remove("show") 34 | 35 | setTimeout(function() { 36 | container.removeChild(backdrop) 37 | container.removeChild(modal) 38 | }, 200) 39 | } 40 | -------------------------------------------------------------------------------- /server/templates/static/manifest.json: -------------------------------------------------------------------------------- 1 | {"name":"mokey","short_name":"mokey","icons":[{"src":"/static/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/images/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} 2 | -------------------------------------------------------------------------------- /server/templates/static/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /server/templates/static/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /server/templates/static/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /server/templates/static/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/fa-v4compatibility.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/fa-v4compatibility.ttf -------------------------------------------------------------------------------- /server/templates/static/webfonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-300.woff -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-300.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-300italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-300italic.woff -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-300italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-300italic.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-500.woff -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-500.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-500italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-500italic.woff -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-500italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-500italic.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-700.woff -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-700.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-700italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-700italic.woff -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-700italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-700italic.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-italic.woff -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-italic.woff2 -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-regular.woff -------------------------------------------------------------------------------- /server/templates/static/webfonts/roboto-v29-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubccr/mokey/e3bb5343acb350b14fd62ffc17975fd69fb652e9/server/templates/static/webfonts/roboto-v29-latin-regular.woff2 -------------------------------------------------------------------------------- /server/templates/verify-account.html: -------------------------------------------------------------------------------- 1 | {{ template "header.html" . }} 2 |
3 | 5 |
6 | 24 | 25 |
26 |
27 | {{ template "footer.html" . }} 28 | -------------------------------------------------------------------------------- /server/templates/verify-success.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /server/token.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 mokey Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD style 3 | // license that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "crypto/rand" 9 | "encoding/hex" 10 | "encoding/json" 11 | "errors" 12 | "math/big" 13 | "time" 14 | 15 | "github.com/essentialkaos/branca/v2" 16 | "github.com/gofiber/fiber/v2" 17 | "github.com/spf13/viper" 18 | ) 19 | 20 | type Token struct { 21 | Username string `json:"username"` 22 | Email string `json:"email"` 23 | Timestamp time.Time `json:"-"` 24 | } 25 | 26 | func GenerateSecret(n int) (string, error) { 27 | secret := make([]byte, n) 28 | _, err := rand.Read(secret) 29 | if err != nil { 30 | return "", err 31 | } 32 | 33 | return hex.EncodeToString(secret), nil 34 | } 35 | 36 | func GenerateSecretString(n int) (string, error) { 37 | const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-!@#$%^&*(){}[]" 38 | ret := make([]byte, n) 39 | for i := 0; i < n; i++ { 40 | num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 41 | if err != nil { 42 | return "", err 43 | } 44 | ret[i] = letters[num.Int64()] 45 | } 46 | 47 | return string(ret), nil 48 | } 49 | 50 | func NewToken(username, email, prefix string, storage fiber.Storage) (string, error) { 51 | tokenIssued, err := storage.Get(prefix + TokenIssuedPrefix + username) 52 | if tokenIssued != nil { 53 | return "", errors.New("token already issued") 54 | } 55 | 56 | claims := &Token{ 57 | Username: username, 58 | Email: email, 59 | } 60 | 61 | jsonBytes, err := json.Marshal(claims) 62 | if err != nil { 63 | return "", err 64 | } 65 | 66 | key, err := hex.DecodeString(viper.GetString("email.token_secret")) 67 | if err != nil { 68 | return "", err 69 | } 70 | 71 | b, err := branca.NewBranca(key) 72 | if err != nil { 73 | return "", err 74 | } 75 | 76 | token, err := b.EncodeToString(jsonBytes) 77 | if err != nil { 78 | return "", err 79 | } 80 | 81 | storage.Set(prefix+TokenIssuedPrefix+username, []byte("true"), time.Until(time.Now().Add(time.Duration(viper.GetInt("email.token_max_age"))*time.Second))) 82 | 83 | return token, nil 84 | } 85 | 86 | func ParseToken(token, prefix string, storage fiber.Storage) (*Token, error) { 87 | tokenUsed, err := storage.Get(prefix + TokenUsedPrefix + token) 88 | if tokenUsed != nil { 89 | return nil, errors.New("token already used") 90 | } 91 | 92 | key, err := hex.DecodeString(viper.GetString("email.token_secret")) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | b, err := branca.NewBranca(key) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | brancaToken, err := b.DecodeString(token) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | if brancaToken.IsExpired(viper.GetUint32("email.token_max_age")) { 108 | return nil, errors.New("Token expired") 109 | } 110 | 111 | var tk Token 112 | err = json.Unmarshal([]byte(brancaToken.Payload()), &tk) 113 | if err != nil { 114 | return nil, err 115 | } 116 | 117 | tk.Timestamp = brancaToken.Timestamp() 118 | 119 | return &tk, nil 120 | } 121 | -------------------------------------------------------------------------------- /server/token_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 mokey Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD style 3 | // license that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "testing" 9 | "time" 10 | 11 | "github.com/gofiber/storage/memory/v2" 12 | "github.com/spf13/viper" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestToken(t *testing.T) { 17 | secret, _ := GenerateSecret(32) 18 | viper.Set("email.token_secret", secret) 19 | viper.Set("email.token_max_age", uint32(3)) 20 | 21 | assert := assert.New(t) 22 | 23 | email := "user@example.com" 24 | uid := "user" 25 | 26 | storage := memory.New() 27 | 28 | token, err := NewToken(uid, email, TokenPasswordReset, storage) 29 | if assert.NoError(err) { 30 | assert.Greater(len(token), 0) 31 | } 32 | 33 | claims, err := ParseToken(token, TokenPasswordReset, storage) 34 | if assert.NoError(err) { 35 | assert.Equal(claims.Username, uid) 36 | assert.Equal(claims.Email, email) 37 | } 38 | 39 | // Should error token already issued 40 | _, err = NewToken(uid, email, TokenPasswordReset, storage) 41 | assert.Error(err) 42 | 43 | time.Sleep(time.Second * 4) 44 | 45 | viper.Set("email.token_max_age", uint32(1)) 46 | 47 | expToken, err := NewToken(uid, email, TokenPasswordReset, storage) 48 | if assert.NoError(err) { 49 | assert.Greater(len(token), 0) 50 | } 51 | 52 | time.Sleep(time.Second * 3) 53 | 54 | _, err = ParseToken(expToken, TokenPasswordReset, storage) 55 | assert.Error(err) 56 | } 57 | -------------------------------------------------------------------------------- /server/usernames.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | valid "github.com/asaskevich/govalidator" 10 | "github.com/spf13/viper" 11 | "github.com/ubccr/goipa" 12 | ) 13 | 14 | var ( 15 | ErrDomainNotAllowed = errors.New("Email domain not allowed") 16 | ErrInvalidUsername = errors.New("Username is invalid. May only include letters, numbers, _, -, .") 17 | 18 | usernameRegx = regexp.MustCompile("^[a-zA-Z0-9_.][a-zA-Z0-9_.-]{0,31}$") 19 | rxUsername = regexp.MustCompile("[^a-zA-Z0-9_.-]") 20 | ) 21 | 22 | func defaultUsernameGenerator(username string) string { 23 | return rxUsername.ReplaceAllString(username, "") 24 | } 25 | 26 | func flastUsernameGenerator(username string) string { 27 | dot := strings.Index(username, ".") 28 | first, last := username[:dot], username[dot+1:] 29 | username = last 30 | if first != "" { 31 | username = string(first[0]) + last 32 | } 33 | return rxUsername.ReplaceAllString(username, "") 34 | } 35 | 36 | func generateUsernameFromEmail(user *ipa.User, allowedDomains map[string]string) error { 37 | at := strings.LastIndex(user.Email, "@") 38 | username, domain := user.Email[:at], strings.ToLower(user.Email[at+1:]) 39 | 40 | if len(allowedDomains) == 0 { 41 | user.Username = defaultUsernameGenerator(username) 42 | } else { 43 | if _, ok := allowedDomains[domain]; !ok { 44 | return fmt.Errorf("%w: %s", ErrDomainNotAllowed, domain) 45 | } 46 | 47 | switch allowedDomains[domain] { 48 | case "flast": 49 | user.Username = flastUsernameGenerator(username) 50 | default: 51 | user.Username = defaultUsernameGenerator(username) 52 | } 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func validateEmail(user *ipa.User, allowedDomains map[string]string) error { 59 | if !valid.IsEmail(user.Email) { 60 | return errors.New("Please provide a valid email address") 61 | } 62 | 63 | if len(allowedDomains) > 0 { 64 | at := strings.LastIndex(user.Email, "@") 65 | _, domain := user.Email[:at], strings.ToLower(user.Email[at+1:]) 66 | 67 | if _, ok := allowedDomains[domain]; !ok { 68 | return fmt.Errorf("%w: %s", ErrDomainNotAllowed, domain) 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | 75 | func validateUsername(user *ipa.User) error { 76 | allowedDomains := viper.GetStringMapString("accounts.allowed_domains") 77 | 78 | if err := validateEmail(user, allowedDomains); err != nil { 79 | return err 80 | } 81 | 82 | if viper.GetBool("accounts.username_from_email") { 83 | if err := generateUsernameFromEmail(user, allowedDomains); err != nil { 84 | return err 85 | } 86 | } 87 | 88 | if !usernameRegx.MatchString(user.Username) { 89 | return fmt.Errorf("%w: %s", ErrInvalidUsername, user.Username) 90 | } 91 | 92 | if valid.IsNumeric(user.Username) { 93 | return errors.New("Username must include at least one letter") 94 | } 95 | 96 | if isBlocked(user.Username) { 97 | return errors.New("Username not allowed. Please try different username or contact the administrator") 98 | } 99 | 100 | user.Username = strings.ToLower(user.Username) 101 | 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /server/usernames_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/ubccr/goipa" 8 | ) 9 | 10 | func TestUsernameFromEmail(t *testing.T) { 11 | assert := assert.New(t) 12 | 13 | allowedDomains := map[string]string{ 14 | "example.edu": "default", 15 | "example.com": "flast", 16 | } 17 | 18 | type testUsername struct { 19 | test string 20 | result string 21 | } 22 | 23 | goodTests := []testUsername{ 24 | testUsername{"user@example.edu", "user"}, 25 | testUsername{"user123@example.edu", "user123"}, 26 | testUsername{"user.test@example.edu", "user.test"}, 27 | testUsername{"user-test@example.edu", "user-test"}, 28 | testUsername{"user@test@example.edu", "usertest"}, 29 | testUsername{"user+test@example.edu", "usertest"}, 30 | testUsername{"first.last@example.com", "flast"}, 31 | testUsername{".last@example.com", "last"}, 32 | } 33 | 34 | for _, utest := range goodTests { 35 | user := &ipa.User{Email: utest.test} 36 | err := generateUsernameFromEmail(user, allowedDomains) 37 | if assert.NoError(err) { 38 | assert.Equal(utest.result, user.Username) 39 | } 40 | } 41 | 42 | badTests := []testUsername{ 43 | testUsername{"user@invalidemail.edu", ""}, 44 | testUsername{"@example.edu", ""}, 45 | } 46 | 47 | for _, utest := range badTests { 48 | user := &ipa.User{Email: utest.test} 49 | err := generateUsernameFromEmail(user, allowedDomains) 50 | assert.Error(err) 51 | } 52 | 53 | } 54 | --------------------------------------------------------------------------------