Compare commits

...

22 Commits

Author SHA1 Message Date
Max Erenberg cf42e49ae6 fix home links
continuous-integration/drone/push Build is passing Details
2024-03-24 18:11:48 -04:00
Max Erenberg bf2afd1195 Remove FCGI support
continuous-integration/drone/push Build is passing Details
Apache's ProxyPass directive doesn't seem to be passing the URI for
FCGI. There's probably a way to configure this, but it's easier to just
use HTTP instead.
2024-03-24 17:54:13 -04:00
Max Erenberg 5f8de94393 restrict app.sock access to www-data
continuous-integration/drone/push Build is passing Details
2024-03-23 23:00:37 -04:00
Max Erenberg 2164ceddf0 fix compilation error in api.go
continuous-integration/drone/push Build is passing Details
2024-03-23 19:32:23 -04:00
Max Erenberg 7716f7bd10 Add web UI for password resets (#123)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #123
2024-03-23 19:26:30 -04:00
Nathan Chung 32cb22665a
packaging: system update commands
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 02:24:41 -05:00
Nathan Chung 56a59186e0
extra packaging instructions and notes
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 02:20:58 -05:00
Nathan Chung 5ecec2b54c
1.0.31: changelog
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 02:20:42 -05:00
Nathan Chung f584a89cec
update pgp packaging info
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 01:53:10 -05:00
Nathan Chung 7d9ec99f8f
release 1.0.31
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 01:40:22 -05:00
Nathan Chung 28adf6e13d
Merge branch '1.0.30-update-packaging'
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 01:39:50 -05:00
Max Erenberg 9c51ad3a01 Allow offsck to add members to the office group (#126)
continuous-integration/drone/push Build is passing Details
Closes #62.

Reviewed-on: #126
2024-02-17 19:31:03 -05:00
Nathan Chung bf7f1c7724
update debian packaging instructions
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-14 14:16:40 -05:00
Leon Zhang 194b5ec4a6 See #125 (#127)
continuous-integration/drone/push Build is passing Details
I couldn't figure out how to reopen a PR after merge, so I'm opening another one.

Co-authored-by: Leon <lzhang219@gmail.com>
Reviewed-on: #127
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
2024-02-11 16:33:39 -05:00
Nathan Chung c83bbe2563 update packaging version and instructions (#122)
continuous-integration/drone/push Build is passing Details
- push version to release version `1.0.30`
- add additional instructions for packaging eg. pgp, additional build dependencies
- git ignore gpg private keys

Reviewed-on: #122
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
Co-committed-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-11 12:20:30 -05:00
Max Erenberg 32709ad401 Revert "Fix for regression in issue #124 (#125)"
continuous-integration/drone/push Build is passing Details
This reverts commit 3780662ba4.
2024-02-10 15:37:06 -05:00
Leon Zhang 3780662ba4 Fix for regression in issue #124 (#125)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Leon <lzhang219@gmail.com>
Reviewed-on: #125
Reviewed-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
2024-02-10 15:32:52 -05:00
Nathan Chung b1dac8ce07
1.0.30: update changelog and package uploaders
continuous-integration/drone/pr Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-04 14:47:10 -05:00
Nathan Chung c5edf5ea48
update packaging version and instructions
continuous-integration/drone/pr Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-04 14:24:22 -05:00
Max Erenberg a4a4ef089c Query Active Directory LDAP for alumni (#120)
continuous-integration/drone/push Build is passing Details
Closes #116.

UWLDAP has program information for current students, so we should continue using it by default.
If the sn attribute (last name) is missing from the entry, then we query ADLDAP instead.

Reviewed-on: #120
2024-02-01 23:57:53 -05:00
Max Erenberg bd1da799c6 Allow ceod/* principals for all requests (#121)
continuous-integration/drone/push Build is passing Details
Allow the ceod/\* principals (which should only be used by the ceod daemons) to make requests to all API endpoints.

Reviewed-on: #121
2024-01-28 21:37:34 -05:00
Leon Zhang 25994af312 Update available positions in configuration files, pertaining to issue #63 (#117)
continuous-integration/drone/push Build is passing Details
Updated available positions adding new positions and removing 2 unused positions in ceo. Passed Drone CI.

Co-authored-by: Leon <lzhang219@gmail.com>
Reviewed-on: #117
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2024-01-22 22:00:30 -05:00
81 changed files with 2854 additions and 107 deletions

17
.drone/adldap_data.ldif Normal file
View File

@ -0,0 +1,17 @@
dn: ou=ADLDAP,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: ADLDAP
dn: cn=alumni1,ou=ADLDAP,dc=csclub,dc=internal
description: One, Alumni
givenName: Alumni
sn: *One
cn: alumni1
sAMAccountName: alumni1
displayName: alumni1
mail: alumni1@alumni.uwaterloo.internal
objectClass: mockADUser
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top

View File

@ -31,13 +31,13 @@ IMAGE__setup_ldap() {
cp .drone/slapd.conf /etc/ldap/slapd.conf
cp .drone/ldap.conf /etc/ldap/ldap.conf
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema
cp .drone/rfc2307bis.schema /etc/ldap/schema/
cp .drone/csc.schema /etc/ldap/schema/
cp .drone/{rfc2307bis,csc,mock_ad}.schema /etc/ldap/schema/
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/
sleep 0.5 && service slapd start
ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:///
if [ -z "$CI" ]; then
ldapadd -c -f .drone/uwldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
ldapadd -c -f .drone/adldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
# setup ldapvi for convenience
apt install -y --no-install-recommends vim ldapvi
grep -q 'export EDITOR' /root/.bashrc || \

View File

@ -186,3 +186,28 @@ objectClass: group
objectClass: posixGroup
cn: office1
gidNumber: 20004
dn: uid=alumni1,ou=People,dc=csclub,dc=internal
cn: Alumni One
givenName: Alumni
sn: One
userPassword: {SASL}alumni1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/alumni1
uid: alumni1
uidNumber: 20005
gidNumber: 20005
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: Alumni
term: w2024
dn: cn=alumni1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: alumni1
gidNumber: 20005

10
.drone/mock_ad.schema Normal file
View File

@ -0,0 +1,10 @@
# Mock Active Directory Schema
attributetype ( 1.3.6.1.4.1.70000.1.1.1 NAME 'sAMAccountName'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )
objectclass ( 1.3.6.1.4.1.70000.1.2.1 NAME 'mockADUser'
SUP top AUXILIARY
MUST ( sAMAccountName ) )

View File

@ -7,6 +7,7 @@ include /etc/ldap/schema/rfc2307bis.schema
include /etc/ldap/schema/inetorgperson.schema
include /etc/ldap/schema/sudo.schema
include /etc/ldap/schema/csc.schema
include /etc/ldap/schema/mock_ad.schema
include /etc/ldap/schema/misc.schema
pidfile /var/run/slapd/slapd.pid
@ -40,6 +41,11 @@ access to *
by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
by * break
# hide most attributes for alumni in mock UWLDAP
access to attrs=cn,sn,givenName,displayName,ou,mail
dn.regex="^uid=alumni[^,]+,ou=(Test)?UWLDAP,dc=csclub,dc=internal$"
by * none
# systems committee get full access
access to *
by dn="cn=ceod,dc=csclub,dc=internal" write

View File

@ -106,3 +106,18 @@ objectClass: person
objectClass: top
uid: exec3
mail: exec3@uwaterloo.internal
dn: uid=alumni1,ou=UWLDAP,dc=csclub,dc=internal
displayName: Alumni One
givenName: Alumni
sn: One
cn: Alumni One
ou: MAT/Mathematics Computer Science
mailLocalAddress: alumni1@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: alumni1
mail: alumni1@uwaterloo.internal

4
.gitignore vendored
View File

@ -1,6 +1,10 @@
# If you update this file, please also update the extend-diff-ignore option
# in debian/source/options.
*.key
*.gpg
*.pgp
__pycache__/
/venv/
/dist/

View File

@ -15,7 +15,10 @@ Make sure you are in the `csc-mirror` group too.
Use Docker/Podman to avoid screwing up your main system.
For example, to create a package for bullseye (replace `podman` with `docker` in all instances below if you're using Docker):
```sh
podman run -it --name pyceo-packaging -v "$PWD":"$PWD":z -w "$PWD" --security-opt="label=disable" debian:bullseye bash
podman run -it --name pyceo-packaging -v "$PWD":"$PWD":z -w "$PWD" --security-opt="label=disable" debian:bookworm bash
# if disconnected from shell, reconnect with:
podman start pyceo-packaging
podman exec -it pyceo-packaging bash
```
**Important**: Make sure to use a container image for the same distribution which you're packaging.
For example, if you're creating a package for bullseye, you should be using the debian:bullseye
@ -26,22 +29,39 @@ Here are some of the prerequisites you'll need to build the deb files
```sh
apt update
apt install -y devscripts debhelper git-buildpackage vim
apt install -y python3-dev python3-venv libkrb5-dev libpq-dev libaugeas0 scdoc # dependencies for building ceo
```
Make sure to also install all of the packages in the 'Build-Depends' section in debian/control.
Update VERSION.txt to the next version, and do a git commit.
Update VERSION.txt to the next version, and do a git commit (or `dpkg-source --commit`).
Now run `dch -i` and edit the changelog.
Now run `dch -i` and edit the changelog (update version, add your uploader name/email, add changes).
Now you will build a signed package. Place your key ID after the `-k` argument, e.g.
```sh
# (pre-requisite) if container doesn't have your gpg key
## step 1: export from host/another computer with your keyring
gpg --armor --output private.key --export-secret-key <your pgp key's id email>
## step 2: import into build container
gpg --import private.key
## step 3: find your key's public key
gpg --list-secret-keys # get key id
## step 4: trust ids (before building)
gpg --edit <pub key id>
gpg> trust # run when gpg editing prompt appears
> 5 # "ultimate" trust
gpg> save # gpg will report no changes were made, but trust of ids should be changed
# alternatively, sign with `debsign` after creating unsigned package
# build (signed) package
gbp buildpackage --git-upstream-branch=master -k8E5568ABB0CF96BC367806ED127923BE10DA48DC --lintian-opts --no-lintian
```
This will create a bunch of files (deb, dsc, tar.gz, etc.) in the parent directory.
Now do another git commit (since you edited the changelog file).
To clean the packages:
To clean the packages (run this after uploading, ie. **do NOT run this if you just finished building**):
```sh
rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
```
@ -49,28 +69,30 @@ rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
## Uploading the package
Inside the container, go up one directory, and create a tarball with all the package files:
```
cd ..
cd .. # within the container, generated files are in the parent directory of your git repo
tar zcvf pyceo.tar.gz *.{xz,gz,dsc,build,buildinfo,changes,deb}
```
Outside of the container (i.e. on your personal machine), copy the tarball out of the
container into your current directory, e.g.
```
podman cp pyceo-packaging:/home/max/repos/pyceo.tar.gz .
# or generally, if you're in the pyceo repo:
podman cp pyceo-packaging:$(cd ../ && pwd)/pyceo.tar.gz .
```
(Replace `/home/max/repos` by the directory in the container with the tarball.)
Now upload the tarball to a CSC machine, e.g.
```
# on "HOST" machine
scp pyceo.tar.gz mannitol:~
```
SSH into that machine and extract the tarball into a separate directory:
```
ssh mannitol
mkdir pyceo-parent
mv pyceo.tar.gz pyceo-parent/
cd pyceo-parent
mkdir pyceo-parent && mv pyceo.tar.gz pyceo-parent/ && cd pyceo-parent
rm -iv *.{xz,gz,dsc,build,buildinfo,changes,deb}
tar zxvf pyceo.tar.gz
```
At this point, you will need a dupload.conf file. Ask someone on syscom for a copy.
At this point, you will need a dupload.conf file. Ask someone on syscom for a copy. Place the dupload config at `~/.dupload.conf` (as per manpage).
Now upload the package to potassium-benzoate:
```
@ -80,7 +102,21 @@ dupload *.changes
Now SSH into potassium-benzoate and run the following:
```
# note: this is AUTOMATICALLY done (within 10-20 minutes by a cron job)
sudo /srv/debian/bin/rrr-incoming
```
To check if mirror has accepted the new package, visit: http://debian.csclub.uwaterloo.ca/dists/bookworm/
<<<<<<< Updated upstream
=======
To update CEO:
```
# repeat this for all systems, starting from ceod servers
sudo apt update
# NOTE: be careful of changing configs!!
sudo apt install --only-upgrade ceod
````
>>>>>>> Stashed changes
There, that wasn't so bad...right? :')

View File

@ -1 +1 @@
1.0.29
1.0.31

View File

@ -1,5 +1,4 @@
import os
import socket
import sys
from zope import component
@ -9,6 +8,7 @@ from .krb_check import krb_check
from .tui.start import main as tui_main
from ceo_common.interfaces import IConfig, IHTTPClient
from ceo_common.model import Config, HTTPClient
from ceo_common.utils import is_in_development
def register_services():
@ -19,8 +19,7 @@ def register_services():
if 'CEO_CONFIG' in os.environ:
config_file = os.environ['CEO_CONFIG']
else:
# This is a hack to determine if we're in the dev env or not
if socket.getfqdn().endswith('.csclub.internal'):
if is_in_development():
config_file = './tests/ceo_dev.ini'
else:
config_file = '/etc/csc/ceo.ini'

View File

@ -1,10 +1,9 @@
import socket
from typing import List, Tuple, Dict
import click
import requests
from ceo_common.utils import is_in_development
from ..utils import space_colon_kv, generic_handle_stream_response
from .CLIStreamResponseHandler import CLIStreamResponseHandler
@ -53,6 +52,6 @@ def handle_sync_response(resp: requests.Response):
def check_if_in_development() -> bool:
"""Aborts if we are not currently in the dev environment."""
if not socket.getfqdn().endswith('.csclub.internal'):
if not is_in_development():
click.echo('This command may only be called during development.')
raise Abort()

View File

@ -5,8 +5,15 @@ position_names = {
'secretary': "Secretary",
'sysadmin': "Sysadmin",
'cro': "Chief Returning Officer",
'librarian': "Librarian",
'imapd': "IMAPD",
'webmaster': "Web Master",
'offsck': "Office Manager",
'events-lead': "Events Lead",
'ext-affairs-lead': "External Affairs Lead",
'marketing-lead': "Marketing Lead",
'design-lead': "Design Lead",
'reps-lead': "Reps Lead",
'mods-lead': "Mods Lead",
'photography-lead': "Photography Lead",
'codey-bot-lead': 'Codey Bot Lead',
'other': 'Other',
}

View File

@ -0,0 +1,18 @@
import typing
from typing import Optional
from zope.interface import Interface
if typing.TYPE_CHECKING:
# FIXME: circular import caused by lifting in __init__.py
from ..model.ADLDAPRecord import ADLDAPRecord
class IADLDAPService(Interface):
"""Represents the AD LDAP database."""
def get_user(username: str) -> Optional['ADLDAPRecord']:
"""
Return the LDAP record for the given user, or
None if no such record exists.
"""

View File

@ -1,20 +1,23 @@
from typing import List, Union
import typing
from typing import List, Optional
from zope.interface import Interface
if typing.TYPE_CHECKING:
# FIXME: circular import caused by lifting in __init__.py
from ..model.UWLDAPRecord import UWLDAPRecord
class IUWLDAPService(Interface):
"""Represents the UW LDAP database."""
def get_user(username: str):
def get_user(username: str) -> Optional['UWLDAPRecord']:
"""
Return the LDAP record for the given user, or
None if no such record exists.
:rtype: Union[UWLDAPRecord, None]
"""
def get_programs_for_users(usernames: List[str]) -> List[Union[str, None]]:
def get_programs_for_users(usernames: List[str]) -> List[Optional[str]]:
"""
Return the programs for the given users from UWLDAP.
If no record or program is found for a user, their entry in

View File

@ -1,3 +1,4 @@
from .IADLDAPService import IADLDAPService
from .ICloudResourceManager import ICloudResourceManager
from .ICloudStackService import ICloudStackService
from .IKerberosService import IKerberosService

View File

@ -0,0 +1,57 @@
import ldap3
from .UWLDAPRecord import UWLDAPRecord
class ADLDAPRecord:
"""Represents a record from the AD LDAP."""
# These are just the ones in which we're interested
ldap_attributes = [
'sAMAccountName',
'mail',
'description',
'sn',
'givenName',
]
def __init__(
self,
sam_account_name: str,
mail: str,
description: str,
sn: str,
given_name: str,
):
self.sam_account_name = sam_account_name
self.mail = mail
self.description = description
self.sn = sn
self.given_name = given_name
@staticmethod
def deserialize_from_ldap(entry: ldap3.Entry):
"""
Deserializes a dict returned from LDAP into an
ADLDAPRecord.
"""
return ADLDAPRecord(
sam_account_name=entry.sAMAccountName.value,
mail=entry.mail.value,
description=entry.description.value,
sn=entry.sn.value,
given_name=entry.givenName.value,
)
def to_uwldap_record(self) -> UWLDAPRecord:
"""Converts this AD LDAP record into a UW LDAP record."""
return UWLDAPRecord(
uid=self.sam_account_name,
mail_local_addresses=[self.mail],
program=None,
# The description attribute has the format "Lastname, Firstname"
cn=' '.join(self.description.split(', ')[::-1]),
# Alumni's last names are prepended with an asterisk
sn=(self.sn[1:] if self.sn[0] == '*' else self.sn),
given_name=self.given_name
)

View File

@ -1,4 +1,6 @@
from .ADLDAPRecord import ADLDAPRecord
from .Config import Config
from .HTTPClient import HTTPClient
from .RemoteMailmanService import RemoteMailmanService
from .Term import Term
from .UWLDAPRecord import UWLDAPRecord

View File

@ -1,6 +1,7 @@
import datetime
import re
from dataclasses import dataclass
import socket
# TODO: disallow underscores. Will break many tests with usernames that include _
VALID_USERNAME_RE = re.compile(r"^[a-z][a-z0-9-_]+$")
@ -70,3 +71,8 @@ def validate_username(username: str) -> UsernameValidationResult:
if not VALID_USERNAME_RE.fullmatch(username):
return UsernameValidationResult(False, 'Username is invalid')
return UsernameValidationResult(True)
def is_in_development() -> bool:
"""This is a hack to determine if we're in the dev env or not"""
return socket.getfqdn().endswith('.csclub.internal')

View File

@ -9,13 +9,13 @@ from .error_handlers import register_error_handlers
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
IMailmanService, IMailService, IUWLDAPService, IHTTPClient, IDatabaseService, \
ICloudStackService, ICloudResourceManager, IKubernetesService, IVHostManager, \
IContainerRegistryService, IClubWebHostingService
IContainerRegistryService, IClubWebHostingService, IADLDAPService
from ceo_common.model import Config, HTTPClient, RemoteMailmanService
from ceod.api.spnego import init_spnego
from ceod.model import KerberosService, LDAPService, FileService, \
MailmanService, MailService, UWLDAPService, CloudStackService, \
CloudResourceManager, KubernetesService, VHostManager, \
ContainerRegistryService, ClubWebHostingService
ContainerRegistryService, ClubWebHostingService, ADLDAPService
from ceod.db import MySQLService, PostgreSQLService
@ -36,6 +36,15 @@ def create_app(flask_config={}):
from ceod.api import members
app.register_blueprint(members.bp, url_prefix='/api/members')
from ceod.api import groups
app.register_blueprint(groups.bp, url_prefix='/api/groups')
from ceod.api import positions
app.register_blueprint(positions.bp, url_prefix='/api/positions')
from ceod.api import uwldap
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
# Only offer mailman API if this host is running Mailman
if hostname == cfg.get('ceod_mailman_host'):
from ceod.api import mailman
@ -53,15 +62,6 @@ def create_app(flask_config={}):
from ceod.api import webhosting
app.register_blueprint(webhosting.bp, url_prefix='/api/webhosting')
from ceod.api import groups
app.register_blueprint(groups.bp, url_prefix='/api/groups')
from ceod.api import positions
app.register_blueprint(positions.bp, url_prefix='/api/positions')
from ceod.api import uwldap
app.register_blueprint(uwldap.bp, url_prefix='/api/uwldap')
register_error_handlers(app)
@app.route('/ping')
@ -112,9 +112,13 @@ def register_services(app):
mail_srv = MailService()
component.provideUtility(mail_srv, IMailService)
# UWLDAPService
uwldap_srv = UWLDAPService()
component.provideUtility(uwldap_srv, IUWLDAPService)
# UWLDAPService, ADLDAPService
if hostname == cfg.get('ceod_admin_host'):
uwldap_srv = UWLDAPService()
component.provideUtility(uwldap_srv, IUWLDAPService)
adldap_srv = ADLDAPService()
component.provideUtility(adldap_srv, IADLDAPService)
# ClubWebHostingService
if hostname == cfg.get('ceod_webhosting_host'):

View File

@ -3,7 +3,8 @@ from flask.json import jsonify
from zope import component
from .utils import authz_restrict_to_syscom, is_truthy, \
create_streaming_response, development_only
create_streaming_response, development_only, requires_admin_creds, \
requires_authentication_no_realm, user_is_in_group
from ceo_common.interfaces import ILDAPService
from ceo_common.utils import fuzzy_result, fuzzy_match
from ceod.transactions.groups import (
@ -52,9 +53,26 @@ def search_group(query, count):
return jsonify(result)
def may_add_user_to_group(auth_username: str, group_name: str) -> bool:
# (is syscom) OR (group is office AND client is offsck)
if user_is_in_group(auth_username, 'syscom'):
return True
if group_name == 'office':
ldap_srv = component.getUtility(ILDAPService)
auth_user = ldap_srv.get_user(auth_username)
if 'offsck' in auth_user.positions:
return True
return False
@bp.route('/<group_name>/members/<username>', methods=['POST'])
@authz_restrict_to_syscom
def add_member_to_group(group_name, username):
@requires_admin_creds
@requires_authentication_no_realm
def add_member_to_group(auth_username: str, group_name: str, username: str):
# Admin creds are required because slapd does not support access control
# rules which use the client's attributes
if not may_add_user_to_group(auth_username, group_name):
return {'error': "not authorized to add user to group"}, 403
subscribe_to_lists = is_truthy(
request.args.get('subscribe_to_lists', 'true')
)
@ -67,8 +85,13 @@ def add_member_to_group(group_name, username):
@bp.route('/<group_name>/members/<username>', methods=['DELETE'])
@authz_restrict_to_syscom
def remove_member_from_group(group_name, username):
@requires_admin_creds
@requires_authentication_no_realm
def remove_member_from_group(auth_username: str, group_name: str, username: str):
# Admin creds are required because slapd does not support access control
# rules which use the client's attributes
if not may_add_user_to_group(auth_username, group_name):
return {'error': "not authorized to add user to group"}, 403
unsubscribe_from_lists = is_truthy(
request.args.get('unsubscribe_from_lists', 'true')
)

View File

@ -158,9 +158,12 @@ def reset_user_password(username: str):
@bp.route('/<username>', methods=['DELETE'])
@requires_admin_creds
@authz_restrict_to_syscom
@development_only
def delete_user(username: str):
# We use the admin creds for the integration tests for the web app, which
# uses the ceod/<host> key
txn = DeleteMemberTransaction(username)
return create_streaming_response(txn)

View File

@ -51,7 +51,12 @@ def requires_admin_creds(f: Callable) -> Callable:
def user_is_in_group(username: str, group_name: str) -> bool:
"""Returns True if `username` is in `group_name`, False otherwise."""
"""
Returns True if `username` is in `group_name` (or starts with "ceod/"),
False otherwise.
"""
if username.startswith("ceod/"):
return True
ldap_srv = component.getUtility(ILDAPService)
group = ldap_srv.get_group(group_name)
return username in group.members

View File

@ -3,15 +3,25 @@ from flask.json import jsonify
from zope import component
from .utils import authz_restrict_to_syscom, is_truthy
from ceo_common.interfaces import IUWLDAPService, ILDAPService
from ceo_common.interfaces import IUWLDAPService, IADLDAPService, ILDAPService
from ceo_common.logger_factory import logger_factory
bp = Blueprint('uwldap', __name__)
logger = logger_factory(__name__)
@bp.route('/<username>')
def get_user(username: str):
uwldap_srv = component.getUtility(IUWLDAPService)
record = uwldap_srv.get_user(username)
if record is not None and not record.sn:
# Alumni are missing a lot of information in UWLDAP.
# Try AD LDAP instead
logger.debug('Querying %s from AD LDAP', username)
adldap_srv = component.getUtility(IADLDAPService)
ad_record = adldap_srv.get_user(username)
if ad_record is not None:
record = ad_record.to_uwldap_record()
if record is None:
return {
'error': 'user not found',

View File

@ -0,0 +1,57 @@
from typing import Optional
import dns.resolver
import ldap3
from zope import component
from zope.interface import implementer
from ceo_common.interfaces import IADLDAPService, IConfig
from ceo_common.logger_factory import logger_factory
from ceo_common.model import ADLDAPRecord
from ceo_common.utils import is_in_development
logger = logger_factory(__name__)
@implementer(IADLDAPService)
class ADLDAPService:
def __init__(self):
cfg = component.getUtility(IConfig)
if is_in_development():
self.adldap_dns_srv_name = None
self.adldap_server_url = cfg.get('adldap_server_url')
else:
self.adldap_dns_srv_name = cfg.get('adldap_dns_srv_name')
# Perform the actual DNS query later so that we don't delay startup
self.adldap_server_url = None
self.adldap_base = cfg.get('adldap_base')
def _get_server_url(self) -> str:
assert self.adldap_dns_srv_name is not None
answers = dns.resolver.resolve(self.adldap_dns_srv_name, 'SRV')
target = answers[0].target.to_text()
# Strip the trailing '.'
target = target[:-1]
logger.debug('Using AD LDAP server %s', target)
# ldaps doesn't seem to work
return 'ldap://' + target
def _get_conn(self) -> ldap3.Connection:
if self.adldap_server_url is None:
self.adldap_server_url = self._get_server_url()
# When get_info=ldap3.SCHEMA (the default), ldap3 tries to search
# for schema information in 'CN=Schema,CN=Configuration,DC=ds,DC=uwaterloo,DC=ca',
# which doesn't exist so a LDAPNoSuchObjectResult exception is raised.
# To avoid this, we tell ldap3 not to look up any server info.
server = ldap3.Server(self.adldap_server_url, get_info=ldap3.NONE)
return ldap3.Connection(
server, auto_bind=True, read_only=True, raise_exceptions=True)
def get_user(self, username: str) -> Optional[ADLDAPRecord]:
conn = self._get_conn()
conn.search(
self.adldap_base, f'(sAMAccountName={username})',
attributes=ADLDAPRecord.ldap_attributes, size_limit=1)
if not conn.entries:
return None
return ADLDAPRecord.deserialize_from_ldap(conn.entries[0])

View File

@ -1,11 +1,11 @@
from typing import Union, List
from typing import List, Optional
import ldap3
from zope import component
from zope.interface import implementer
from .UWLDAPRecord import UWLDAPRecord
from ceo_common.interfaces import IUWLDAPService, IConfig
from ceo_common.model import UWLDAPRecord
@implementer(IUWLDAPService)
@ -20,7 +20,7 @@ class UWLDAPService:
self.uwldap_server_url, auto_bind=True, read_only=True,
raise_exceptions=True)
def get_user(self, username: str) -> Union[UWLDAPRecord, None]:
def get_user(self, username: str) -> Optional[UWLDAPRecord]:
conn = self._get_conn()
conn.search(
self.uwldap_base, f'(uid={username})',
@ -29,7 +29,7 @@ class UWLDAPService:
return None
return UWLDAPRecord.deserialize_from_ldap(conn.entries[0])
def get_programs_for_users(self, usernames: List[str]) -> List[Union[str, None]]:
def get_programs_for_users(self, usernames: List[str]) -> List[Optional[str]]:
filter_str = '(|' + ''.join([f'(uid={uid})' for uid in usernames]) + ')'
programs = [None] * len(usernames)
user_indices = {uid: i for i, uid in enumerate(usernames)}

View File

@ -1,3 +1,4 @@
from .ADLDAPService import ADLDAPService
from .CloudResourceManager import CloudResourceManager
from .CloudStackService import CloudStackService
from .KerberosService import KerberosService
@ -5,7 +6,6 @@ from .LDAPService import LDAPService, UserNotFoundError, GroupNotFoundError
from .User import User
from .Group import Group
from .UWLDAPService import UWLDAPService
from .UWLDAPRecord import UWLDAPRecord
from .FileService import FileService
from .SudoRole import SudoRole
from .MailService import MailService

15
debian/changelog vendored
View File

@ -1,3 +1,18 @@
ceo (1.0.31-bookworm1) bookworm; urgency=medium
* Allow office manager to manage office group
* Fix regression issue for new positions
-- Nathan <n4chung@csclub.uwaterloo.ca> Wed, 21 Feb 2024 06:40:29 +0000
ceo (1.0.30-bookworm1) bookworm; urgency=medium
* Support for new positions
* Improved username validation
* UWLDAP API integration for alumni information
-- Nathan <n4chung@csclub.uwaterloo.ca> Sun, 04 Feb 2024 19:38:18 +0000
ceo (1.0.29-bookworm1) bookworm; urgency=medium
* Upgrade dependencies

1
debian/control vendored
View File

@ -7,6 +7,7 @@ Vcs-Git: https://git.csclub.uwaterloo.ca/public/pyceo.git
Vcs-Browser: https://git.csclub.uwaterloo.ca/public/pyceo
Uploaders: Max Erenberg <merenber@csclub.uwaterloo.ca>,
Raymond Li <raymo@csclub.uwaterloo.ca>,
Nathan <n4chung@csclub.uwaterloo.ca>,
Edwin <e42zhang@csclub.uwaterloo.ca>
Build-Depends: debhelper (>= 13),
python3-dev (>= 3.9),

View File

@ -1,15 +1,17 @@
version: "3.6"
x-common: &common
image: ceo-generic:bullseye
volumes:
- ceo-venv:/app/venv:ro
- ./.drone:/app/.drone:ro
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh:ro
- ceo-venv:/app/venv:ro
- ./ceo:/app/ceo:ro
- ./ceo_common:/app/ceo_common:ro
- ./ceod:/app/ceod:ro
- ./tests:/app/tests:ro
# for flake8
- ./setup.cfg:/app/setup.cfg:ro
- ./web:/app/web:z
security_opt:
- label:disable
working_dir: /app
@ -18,6 +20,7 @@ x-common: &common
x-ceod-common: &ceod-common
<<: *common
image: ceo-generic:bullseye
environment:
FLASK_APP: ceod.api
FLASK_DEBUG: "true"

View File

@ -18,8 +18,11 @@ port = 9987
[positions]
required = president,vice-president,sysadmin
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck
available = president,vice-president,sysadmin,treasurer,
secretary,cro,webmaster,offsck,ext-affairs-lead,
marketing-lead,design-lead,events-lead,reps-lead,
mods-lead,photography-lead,codey-bot-lead,other
[mysql]
host = caffeine

View File

@ -30,6 +30,10 @@ sudo_base = ou=SUDOers,dc=csclub,dc=uwaterloo,dc=ca
server_url = ldaps://uwldap.uwaterloo.ca
base = dc=uwaterloo,dc=ca
[adldap]
dns_srv_name = _ldap._tcp.teaching.ds.uwaterloo.ca
base = dc=teaching,dc=ds,dc=uwaterloo,dc=ca
[members]
min_id = 20001
max_id = 29999
@ -63,8 +67,10 @@ exec = exec,exec-moderators
[positions]
required = president,vice-president,sysadmin
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck
available = president,vice-president,sysadmin,treasurer,
secretary,cro,webmaster,offsck,ext-affairs-lead,
marketing-lead,design-lead,events-lead,reps-lead,
mods-lead,photography-lead,codey-bot-lead,other
[mysql]
# This is only used on the database_host.

View File

@ -1,5 +1,6 @@
click==8.1.6
cryptography==41.0.2
dnspython==2.5.0
Flask==2.3.2
gssapi==1.8.2
gunicorn==21.2.0

View File

@ -35,6 +35,7 @@ if ! $DOCKER volume exists ceo-venv; then
$DOCKER volume create ceo-venv
$DOCKER run \
--rm \
--security-opt label=disable \
-w /app/venv \
-v ceo-venv:/app/venv:z \
-v ./requirements.txt:/tmp/requirements.txt:ro \

View File

@ -136,12 +136,13 @@ def test_members_renew(cli_setup, ldap_user, g_admin_ctx):
assert result.output == expected
def test_members_pwreset(cli_setup, ldap_user, krb_user):
def test_members_pwreset(cli_setup, ldap_and_krb_user):
uid = ldap_and_krb_user.uid
runner = CliRunner()
result = runner.invoke(
cli, ['members', 'pwreset', ldap_user.uid], input='y\n')
cli, ['members', 'pwreset', uid], input='y\n')
expected_pat = re.compile((
f"^Are you sure you want to reset {ldap_user.uid}'s password\\? \\[y/N\\]: y\n"
f"^Are you sure you want to reset {uid}'s password\\? \\[y/N\\]: y\n"
"New password: \\S+\n$"
), re.MULTILINE)
assert result.exit_code == 0

View File

@ -39,16 +39,23 @@ def test_positions(cli_setup, g_admin_ctx):
assert result.exit_code == 0
assert result.output == '''
The positions will be updated:
president: test_0
vice-president: test_1
sysadmin: test_2
secretary: test_3
webmaster: test_4
president: test_0
vice-president: test_1
sysadmin: test_2
secretary: test_3
webmaster: test_4
treasurer:
cro:
librarian:
imapd:
offsck:
ext-affairs-lead:
marketing-lead:
design-lead:
events-lead:
reps-lead:
mods-lead:
photography-lead:
codey-bot-lead:
other:
Do you want to continue? [y/N]: y
Update positions in LDAP... Done
Update executive group in LDAP... Done
@ -108,16 +115,23 @@ def test_positions_multiple_users(cli_setup, g_admin_ctx):
assert result.exit_code == 0
assert result.output == '''
The positions will be updated:
president: test_0
vice-president: test_1, test_2
sysadmin: test_2
secretary: test_3, test_4, test_2
president: test_0
vice-president: test_1, test_2
sysadmin: test_2
secretary: test_3, test_4, test_2
treasurer:
cro:
librarian:
imapd:
webmaster:
offsck:
ext-affairs-lead:
marketing-lead:
design-lead:
events-lead:
reps-lead:
mods-lead:
photography-lead:
codey-bot-lead:
other:
Do you want to continue? [y/N]: y
Update positions in LDAP... Done
Update executive group in LDAP... Done

View File

@ -14,8 +14,10 @@ port = 9987
[positions]
required = president,vice-president,sysadmin
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck
available = president,vice-president,sysadmin,treasurer,
secretary,cro,webmaster,offsck,ext-affairs-lead,
marketing-lead,design-lead,events-lead,reps-lead,
mods-lead,photography-lead,codey-bot-lead,other
[mysql]
host = coffee

View File

@ -5,8 +5,8 @@ from mysql.connector import connect
from mysql.connector.errors import ProgrammingError
def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_user.uid
def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
uid = ldap_and_krb_user.uid
with g_admin_ctx():
user = User(uid='someone_else', cn='Some Name', given_name='Some',
@ -72,8 +72,8 @@ def test_api_create_mysql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
user.remove_from_ldap()
def test_api_passwd_reset_mysql(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_user.uid
def test_api_passwd_reset_mysql(cfg, client, g_admin_ctx, ldap_and_krb_user):
uid = ldap_and_krb_user.uid
with g_admin_ctx():
user = User(uid='someone_else', cn='Some Name', given_name='Some',

View File

@ -4,8 +4,8 @@ from ceod.model import User
from psycopg2 import connect, OperationalError, ProgrammingError
def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_user.uid
def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_and_krb_user):
uid = ldap_and_krb_user.uid
with g_admin_ctx():
user = User(uid='someone_else', cn='Some Name', given_name='Some',
@ -74,8 +74,8 @@ def test_api_create_psql_db(cfg, client, g_admin_ctx, ldap_user, krb_user):
user.remove_from_ldap()
def test_api_passwd_reset_psql(cfg, client, g_admin_ctx, ldap_user, krb_user):
uid = ldap_user.uid
def test_api_passwd_reset_psql(cfg, client, g_admin_ctx, ldap_and_krb_user):
uid = ldap_and_krb_user.uid
with g_admin_ctx():
user = User(uid='someone_else', cn='Some Name', given_name='Some',

View File

@ -2,6 +2,7 @@ import ldap3
import pytest
from ceod.model import Group
from tests.conftest_ceod_api import expect_successful_txn
def test_api_group_not_found(client):
@ -9,20 +10,28 @@ def test_api_group_not_found(client):
assert status == 404
@pytest.fixture(scope='module')
def create_group_resp(client):
def create_group(client, cn: str, description: str):
status, data = client.post('/api/groups', json={
'cn': 'test_group1',
'description': 'Test Group One',
'cn': cn,
'description': description,
})
assert status == 200
assert data[-1]['status'] == 'completed'
yield status, data
status, data = client.delete('/api/groups/test_group1')
return status, data
def delete_group(client, cn: str):
status, data = client.delete(f'/api/groups/{cn}')
assert status == 200
assert data[-1]['status'] == 'completed'
@pytest.fixture(scope='module')
def create_group_resp(client):
yield create_group(client, 'test_group1', 'Test Group One')
delete_group(client, 'test_group1')
@pytest.fixture(scope='module')
def create_group_result(create_group_resp):
# convenience method
@ -126,12 +135,7 @@ def create_random_names():
def create_searchable_groups(client, create_random_names):
random_names = create_random_names
for name in random_names:
status, data = client.post('/api/groups', json={
'cn': name,
'description': 'Groups with distinct names for testing searching',
})
assert status == 200
assert data[-1]['status'] == 'completed'
create_group(client, name, 'Groups with distinct names for testing searching')
yield random_names
@ -210,3 +214,27 @@ def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx):
for group in groups:
if group.cn != 'syscom':
group.remove_from_ldap()
@pytest.mark.parametrize('desc,expect_success', [
('in_syscom', True),
('is_offsck', True),
(None, False),
])
def test_api_add_or_remove_member_from_office(desc, expect_success, cfg, client, ldap_and_krb_user, new_user, g_admin_ctx):
uid = ldap_and_krb_user.uid
if desc == 'in_syscom':
uid = 'ctdalek'
elif desc == 'is_offsck':
with g_admin_ctx():
ldap_and_krb_user.set_positions(['offsck'])
def handle_resp(status_and_data):
if expect_success:
expect_successful_txn(status_and_data)
else:
status = status_and_data[0]
assert status == 403
handle_resp(client.post(f'/api/groups/office/members/{new_user.uid}', principal=uid))
handle_resp(client.delete(f'/api/groups/office/members/{new_user.uid}', principal=uid))

View File

@ -99,6 +99,28 @@ def test_set_positions(cfg, client, g_admin_ctx, mock_mailman_server):
'vice-president': ['test2', 'test3'],
'sysadmin': ['test2', 'test3', 'test4'],
}}
# check every single psition
status, data = client.post('/api/positions', json={
'president': 'test1',
'vice-president': 'test1',
'treasurer': 'test1',
'secretary': 'test1',
'sysadmin': 'test1',
'cro': 'test1',
'webmaster': 'test1',
'offsck': 'test1',
'ext-affairs-lead': 'test1',
'marketing-lead': 'test1',
'design-lead': 'test1',
'events-lead': 'test1',
'reps-lead': 'test1',
'mods-lead': 'test1',
'photography-lead': 'test1',
'codey-bot-lead': 'test1',
'other': 'test1',
})
assert status == 200
assert data[-1]['status'] == 'completed'
finally:
with g_admin_ctx():
for user in users:

View File

@ -50,3 +50,19 @@ def test_updateprograms(
# make sure that the user was changed
status, data = client.get(f'/api/members/{uwldap_user.uid}')
assert data['program'] == 'New Program'
def test_get_alumni(client):
uid = 'alumni1'
status, data = client.get(f'/api/uwldap/{uid}')
assert status == 200
expected = {
"cn": 'Alumni One',
"given_name": 'Alumni',
"sn": 'One',
# This should be the email address from AD LDAP
"mail_local_addresses": [f'{uid}@alumni.uwaterloo.internal'],
# Program attribute should be missing
"uid": uid,
}
assert data == expected

View File

@ -27,6 +27,10 @@ sudo_base = ou=SUDOers,dc=csclub,dc=internal
server_url = ldap://auth1.csclub.internal
base = ou=UWLDAP,dc=csclub,dc=internal
[adldap]
server_url = ldap://auth1.csclub.internal
base = ou=ADLDAP,dc=csclub,dc=internal
[members]
min_id = 20001
max_id = 29999
@ -59,8 +63,10 @@ exec = exec
[positions]
required = president,vice-president,sysadmin
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck
available = president,vice-president,sysadmin,treasurer,
secretary,cro,webmaster,offsck,ext-affairs-lead,
marketing-lead,design-lead,events-lead,reps-lead,
mods-lead,photography-lead,codey-bot-lead,other
[mysql]
username = mysql

View File

@ -26,6 +26,10 @@ sudo_base = ou=TestSUDOers,dc=csclub,dc=internal
server_url = ldap://auth1.csclub.internal
base = ou=TestUWLDAP,dc=csclub,dc=internal
[adldap]
server_url = ldap://auth1.csclub.internal
base = ou=TestADLDAP,dc=csclub,dc=internal
[members]
# 20000 is ctdalek, 20001 is office1
min_id = 20002
@ -58,8 +62,10 @@ exec = exec
[positions]
required = president,vice-president,sysadmin
available = president,vice-president,treasurer,secretary,
sysadmin,cro,librarian,imapd,webmaster,offsck
available = president,vice-president,sysadmin,treasurer,
secretary,cro,webmaster,offsck,ext-affairs-lead,
marketing-lead,design-lead,events-lead,reps-lead,
mods-lead,photography-lead,codey-bot-lead,other
[mysql]
username = mysql

View File

@ -30,15 +30,16 @@ from .utils import ( # noqa: F401
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, \
IFileService, IMailmanService, IHTTPClient, IUWLDAPService, IMailService, \
IDatabaseService, ICloudStackService, IKubernetesService, IVHostManager, \
ICloudResourceManager, IContainerRegistryService, IClubWebHostingService
from ceo_common.model import Config, HTTPClient, Term
ICloudResourceManager, IContainerRegistryService, IClubWebHostingService, \
IADLDAPService
from ceo_common.model import Config, HTTPClient, Term, UWLDAPRecord
import ceo_common.utils
from ceod.api import create_app
from ceod.db import MySQLService, PostgreSQLService
from ceod.model import KerberosService, LDAPService, FileService, User, \
MailmanService, Group, UWLDAPService, UWLDAPRecord, MailService, \
MailmanService, Group, UWLDAPService, MailService, \
CloudStackService, KubernetesService, VHostManager, CloudResourceManager, \
ContainerRegistryService, ClubWebHostingService
ContainerRegistryService, ClubWebHostingService, ADLDAPService
from .MockSMTPServer import MockSMTPServer
from .MockMailmanServer import MockMailmanServer
from .MockCloudStackServer import MockCloudStackServer
@ -309,6 +310,17 @@ def uwldap_srv(cfg, ldap_conn):
'givenName': 'Calum',
},
)
conn.add(
f'uid=alumni1,{base_dn}',
['inetLocalMailRecipient', 'inetOrgPerson', 'organizationalPerson', 'person'],
{
'mailLocalAddress': 'alumni1@uwaterloo.internal',
'ou': 'Alumni',
'cn': 'Alumni',
'sn': 'One',
'givenName': 'Alumni',
},
)
_uwldap_srv = UWLDAPService()
component.getGlobalSiteManager().registerUtility(_uwldap_srv, IUWLDAPService)
yield _uwldap_srv
@ -316,6 +328,31 @@ def uwldap_srv(cfg, ldap_conn):
delete_subtree(conn, base_dn)
@pytest.fixture(scope='session')
def adldap_srv(cfg, ldap_conn):
conn = ldap_conn
base_dn = cfg.get('adldap_base')
delete_subtree(conn, base_dn)
conn.add(base_dn, 'organizationalUnit')
conn.add(
f'cn=alumni1,{base_dn}',
['mockADUser', 'inetOrgPerson', 'organizationalPerson', 'person'],
{
'description': 'One, Alumni',
'givenName': 'Alumni',
'sn': '*One',
'cn': 'alumni1',
'sAMAccountName': 'alumni1',
'displayName': 'alumni1',
'mail': 'alumni1@alumni.uwaterloo.internal',
},
)
_adldap_srv = ADLDAPService()
component.getGlobalSiteManager().registerUtility(_adldap_srv, IADLDAPService)
yield _adldap_srv
delete_subtree(conn, base_dn)
@pytest.fixture(scope='session')
def webhosting_srv():
with tempfile.TemporaryDirectory() as tmpdir:
@ -376,14 +413,25 @@ def postgresql_srv(cfg):
return psql_srv
def delete_dir_contents(dir: str):
if os.path.isdir(dir):
for item in os.listdir(dir):
path = os.path.join(dir, item)
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.unlink(path)
@pytest.fixture(scope='session')
def vhost_dir_setup(cfg):
state_dir = '/run/ceod'
if os.path.isdir(state_dir):
shutil.rmtree(state_dir)
os.makedirs(state_dir)
# Don't delete the directory itself because the non-test ceod process
# is using it
delete_dir_contents(state_dir)
os.makedirs(state_dir, exist_ok=True)
yield
shutil.rmtree(state_dir)
delete_dir_contents(state_dir)
@pytest.fixture(scope='session')
@ -422,6 +470,7 @@ def app(
file_srv,
mailman_srv,
uwldap_srv,
adldap_srv,
mail_srv,
mysql_srv,
postgresql_srv,
@ -489,6 +538,12 @@ def krb_user(simple_user):
simple_user.remove_from_kerberos()
@pytest.fixture
def ldap_and_krb_user(ldap_user, krb_user):
# Note that ldap_user and krb_user are both the same person (simple_user)
yield ldap_user
@pytest.fixture
def new_user_gen(
client, g_admin_ctx, ldap_srv_session, mocks_for_create_user, # noqa: F811

View File

@ -1,5 +1,6 @@
import json
import socket
from typing import Any, Dict, List, Tuple
import flask
from flask.testing import FlaskClient
@ -76,3 +77,9 @@ class CeodTestClient:
def put(self, path, principal=None, need_auth=True, delegate=True, **kwargs):
return self.request('PUT', path, principal, need_auth, delegate, **kwargs)
def expect_successful_txn(status_and_data: Tuple[int, List[Dict[str, Any]]]) -> None:
status, data = status_and_data
assert status == 200
assert data[-1]['status'] == 'completed'

3
web/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/ceod-web
/app.sock
/test

80
web/README.md Normal file
View File

@ -0,0 +1,80 @@
# ceod-web
This directory contains an experimental self-service web portal for CSC
members to use. It is an alternative to the ceo TUI. Currently it is only
meant to be used by general members, but in the future it may be extended
to be used by syscom/office members as well.
Implemented APIs:
- [x] Password reset
- [ ] Change login shell
- [ ] Change forwarding addresses
- [ ] Show membership terms
## Development
Make sure the Docker containers for ceo are running. Build the "web"
executable on the host, then run it in the phosphoric-acid container:
```sh
# Don't use cgo because the glibc version in the container will likely be
# older than the one on the host
CGO_ENABLED=0 go build -o ceod-web
docker-compose exec phosphoric-acid bash
cd web
./ceod-web -c dev.json
```
The application will listen on a Unix socket. In production, it expects to
receive ADFS information from Apache, which is acting as a reverse proxy.
In development, we will use our own proxy instead:
```sh
# On the host
go run scripts/proxy.go -s app.sock -u ctdalek -f Calum
```
Now you should be able to visit http://localhost:9988 in your browser.
NOTE: If you are not accessing the website via "localhost" (e.g. you are using
some custom proxy setup), then you need to modify the value of "app_url" in
dev.json. The app_url value must be equal to the base URL which you enter in
your browser's address bar, otherwise the cookie domain will not match.
You can change the `-u` (username) and `-f` (first name) arguments to the proxy.go
program to simulate a different user. See the .drone/data.ldif file in the parent
directory to see all mock users.
### Templates and static assets
In development, the templated views and static assets will be loaded from the
internal/views and internal/static folders, respectively. Unfortunately I think
that the Echo framework caches those files internally so you will need to
restart the app process if you modify them.
### Emails
By default, no emails will be sent; they will only be printed to stdout. To
send real emails, add these fields to the dev.json (replace `your_username`):
```json
{
...
"mta": "mail.csclub.uwaterloo.ca:25",
"forced_email_recipient": "your_username@csclub.uwaterloo.ca"
}
```
This will send all of the emails to your email address (the `To` header will
still be preserved, however).
## Tests
```sh
# On the host
CGO_ENABLED=0 go test -c -o test ./tests
# In the container
./test -test.v
```
## Deployment
Apache configuration on caffeine:
```
Redirect permanent /ceo /ceo/
<Location /ceo/ >
Include snippets/adfs-require-auth.conf
Include snippets/adfs-set-headers.conf
ProxyPass "unix:/run/ceod-web/app.sock|http://ceod-web/"
ProxyPassReverse "unix:/run/ceod-web/app.sock|http://ceod-web/"
</Location>
```

7
web/dev.json Normal file
View File

@ -0,0 +1,7 @@
{
"app_url": "http://localhost:9988",
"socket_path": "app.sock",
"csc_domain": "csclub.internal",
"uw_domain": "uwaterloo.internal",
"dev": true
}

34
web/go.mod Normal file
View File

@ -0,0 +1,34 @@
module git.csclub.uwaterloo.ca/public/pyceo/web
go 1.21.4
require (
github.com/PuerkitoBio/goquery v1.8.1
github.com/jcmturner/gokrb5/v8 v8.4.4
github.com/labstack/echo/v4 v4.11.4
github.com/labstack/gommon v0.4.2
github.com/stretchr/testify v1.8.4
)
require (
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

103
web/go.sum Normal file
View File

@ -0,0 +1,103 @@
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

180
web/internal/api/api.go Normal file
View File

@ -0,0 +1,180 @@
package api
import (
"fmt"
"html/template"
"io"
"io/fs"
"net"
"net/http"
"os"
"os/exec"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/labstack/gommon/log"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
type TemplateManager struct {
templates map[string]*template.Template
}
func newTemplateManager(isDev bool) *TemplateManager {
var viewsFS fs.FS
if isDev {
viewsFS = os.DirFS("internal/views")
} else {
var err error
viewsFS, err = fs.Sub(internal.EmbeddedViews, "views")
if err != nil {
panic(err)
}
}
base := template.Must(template.ParseFS(viewsFS, "base.html"))
// Adapted from https://stackoverflow.com/a/24120195
parse := func(filenames ...string) *template.Template {
clone := template.Must(base.Clone())
return template.Must(clone.ParseFS(viewsFS, filenames...))
}
return &TemplateManager{templates: map[string]*template.Template{
"root": parse("root.html"),
"pwreset": parse("pwreset.html"),
"pwreset_confirmation": parse("pwreset_confirmation.html"),
"app_error": parse("app_error.html"),
"4xx": parse("4xx.html"),
"500": parse("500.html"),
}}
}
func (t *TemplateManager) Render(w io.Writer, name string, data any, c echo.Context) error {
tmpl, ok := t.templates[name]
if !ok {
c.Logger().Errorf("No such template '%s'", name)
return echo.NewHTTPError(http.StatusInternalServerError, "Internal server error")
}
return tmpl.Execute(w, data)
}
func addStaticAssets(isDev bool, e *echo.Echo) {
var staticFS fs.FS
if isDev {
staticFS = os.DirFS("internal")
} else {
staticFS = internal.EmbeddedAssets
}
e.Group("static").Use(middleware.StaticWithConfig(middleware.StaticConfig{
Root: "static",
Filesystem: http.FS(staticFS),
}))
}
func newListener(cfg *config.Config) net.Listener {
sockPath := cfg.SocketPath
if _, err := os.Stat(sockPath); err == nil {
err = os.Remove(sockPath)
if err != nil {
panic(fmt.Errorf("Could not remove %s: %w", sockPath, err))
}
}
l, err := net.Listen("unix", sockPath)
if err != nil {
panic(err)
}
if !cfg.IsDev && !cfg.NoSocketAuth {
// Only www-data should be allowed to write to the socket, otherwise
// users could forge the X-CSC-ADFS-* headers and reset other people's
// passwords
err = os.Chmod(sockPath, 0)
if err != nil {
panic(err)
}
cmd := exec.Command("/bin/setfacl", "-m", "u:www-data:rw", sockPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
panic(err)
}
}
return l
}
func NewAPI(cfg *config.Config, ceodSrv model.CeodService, mailSrv service.MailService) *echo.Echo {
e := echo.New()
app := app.NewApp(cfg, mailSrv)
e.Logger.SetLevel(log.DEBUG)
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.RequestID())
e.Use(helmet(cfg))
addStaticAssets(cfg.IsDev, e)
e.Use(appContextMiddleware(app))
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
TokenLookup: "cookie:" + cfg.CookieName,
CookieName: "ceod-web-csrf",
CookiePath: cfg.GetAppPath(),
CookieDomain: cfg.GetAppHostname(),
CookieSecure: !cfg.IsDev,
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
}))
e.HTTPErrorHandler = httpErrorHandler
e.Renderer = newTemplateManager(cfg.IsDev)
addRoutes(e)
return e
}
func Start(cfg *config.Config) {
e := NewAPI(cfg, service.NewCeodService(cfg), service.NewMailService(cfg))
listener := newListener(cfg)
e.Logger.Info("Listening on " + cfg.SocketPath)
e.Logger.Fatal(http.Serve(listener, e))
}
func addRoutes(e *echo.Echo) {
e.GET("/", getRoot)
e.GET("/pwreset", getPwreset)
e.POST("/pwreset", postPwreset)
}
func getRoot(c echo.Context) error {
ac := c.(*appContext)
_, err := ac.app.GetReqUser(ac)
if err != nil {
return err
}
return c.Render(http.StatusOK, "root", map[string]any{"firstName": ac.req.GivenName})
}
func getPwreset(c echo.Context) error {
ac := c.(*appContext)
emailAddress, err := ac.app.PwresetCheck(ac)
if err != nil {
return err
}
// Double-submit cookie
renderData := map[string]any{
"emailAddress": emailAddress,
"csrf": c.Get("csrf"),
}
return c.Render(http.StatusOK, "pwreset", renderData)
}
func postPwreset(c echo.Context) error {
if c.Get("csrf") != c.FormValue("_csrf") {
return newApiError(ERROR_INVALID_CSRF_TOKEN)
}
ac := c.(*appContext)
emailAddress, err := ac.app.Pwreset(ac)
if err != nil {
return err
}
renderData := map[string]any{"emailAddress": emailAddress}
return c.Render(http.StatusOK, "pwreset_confirmation", renderData)
}

View File

@ -0,0 +1,99 @@
package api
import (
"html/template"
"net/http"
"github.com/labstack/echo/v4"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
)
const (
ERROR_INVALID_CSRF_TOKEN = iota + 1
)
const (
membershipURL = "https://csclub.uwaterloo.ca/get-involved/"
syscomURL = "mailto:syscom@csclub.uwaterloo.ca"
)
type ApiError struct {
Code int
}
func newApiError(code int) ApiError {
return ApiError{Code: code}
}
func (a ApiError) Error() string {
return "API error"
}
func renderHTTPErrorPage(c echo.Context, code int, name string) {
data := map[string]any{"statusText": http.StatusText(code)}
if err := c.Render(code, name, data); err != nil {
c.Logger().Error(err)
// Fall back to plain text response
_ = c.String(http.StatusInternalServerError, "Internal server error")
}
}
func renderAppErrorPage(c echo.Context, renderInfo *errorRenderInfo) {
if err := c.Render(http.StatusOK, "app_error", renderInfo); err != nil {
c.Logger().Error(err)
// Fall back to plain text response
_ = c.String(http.StatusInternalServerError, "Internal server error")
}
}
type errorRenderInfo struct {
Title string
HtmlFragment template.HTML
}
func newErrorRenderInfo(title string, htmlFragment string) *errorRenderInfo {
return &errorRenderInfo{Title: title, HtmlFragment: template.HTML(htmlFragment)}
}
var appErrorInfos = map[int]*errorRenderInfo{
app.ERROR_NO_SUCH_USER: newErrorRenderInfo(
"You are not a CSC member :(",
"It seems like you're not a CSC member yet. "+
`Maybe you'd like to <a href="`+membershipURL+`">become one instead</a>?`),
app.ERROR_MEMBERSHIP_EXPIRED: newErrorRenderInfo(
"Your membership has expired",
`Please visit <a href="`+membershipURL+`">this page</a> for instructions `+
"on how to renew your membership."),
app.ERROR_OTHER: newErrorRenderInfo(
"Something went wrong",
"We seem to be having issues on our end. Please contact the "+
`<a href="`+syscomURL+`">Systems Committee</a> for assistance.`),
app.ERROR_FAILED_TO_SEND_PWRESET_EMAIL: newErrorRenderInfo(
"Something went wrong",
"We weren't able to send the new password to your email address. Please "+
`contact the <a href="`+syscomURL+`">Systems Committee</a> for assistance.`),
}
var apiErrorInfos = map[int]*errorRenderInfo{
ERROR_INVALID_CSRF_TOKEN: newErrorRenderInfo(
"Invalid CSRF token",
"Please go back and refresh the page to get a new token. If this "+
`error persists, please contact the <a href="`+syscomURL+`">`+
"Systems Committee</a>."),
}
func httpErrorHandler(err error, c echo.Context) {
if httpErr, ok := err.(*echo.HTTPError); ok {
if httpErr.Code/100 == 4 {
renderHTTPErrorPage(c, httpErr.Code, "4xx")
return
}
} else if appErr, ok := err.(app.AppError); ok {
renderAppErrorPage(c, appErrorInfos[appErr.Code])
return
} else if apiErr, ok := err.(ApiError); ok {
renderAppErrorPage(c, apiErrorInfos[apiErr.Code])
return
}
c.Logger().Error(err)
renderHTTPErrorPage(c, http.StatusInternalServerError, "500")
}

View File

@ -0,0 +1,93 @@
package api
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/labstack/echo/v4"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/app"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
)
func helmet(cfg *config.Config) echo.MiddlewareFunc {
cspSchemes := "https:"
if cfg.IsDev {
cspSchemes = "http: https:"
}
cspDirectives := []string{
"default-src 'self'",
"base-uri 'self'",
"font-src 'self' " + cspSchemes + " data:",
"form-action 'self'",
"frame-ancestors 'self'",
"img-src 'self' data:",
"object-src 'none'",
"script-src 'self'",
"script-src-attr 'none'",
"style-src 'self' " + cspSchemes + " 'unsafe-inline'",
}
if !cfg.IsDev {
cspDirectives = append(cspDirectives, "upgrade-insecure-requests")
}
csp := strings.Join(cspDirectives, ";")
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
h := c.Response().Header()
h.Set(echo.HeaderContentSecurityPolicy, csp)
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Cross-Origin-Resource-Policy", "same-origin")
h.Set(echo.HeaderReferrerPolicy, "no-referrer")
if cfg.HstsMaxAge != 0 {
h.Set(
echo.HeaderStrictTransportSecurity,
"max-age="+strconv.FormatInt(int64(cfg.HstsMaxAge), 10),
)
}
return next(c)
}
}
}
func getReqInfoFromHTTPHeaders(r *http.Request) (*app.ReqInfo, error) {
// header names must be in canonical form (see http.CanonicalHeaderKey)
usernames := r.Header["X-Csc-Adfs-Username"]
if len(usernames) == 0 {
return nil, errors.New("Username is missing from HTTP headers")
}
givenNames := r.Header["X-Csc-Adfs-Firstname"]
if len(givenNames) == 0 {
return nil, errors.New("Given name is missing from HTTP headers")
}
return &app.ReqInfo{Username: usernames[0], GivenName: givenNames[0]}, nil
}
type appContext struct {
echo.Context
req *app.ReqInfo
app *app.App
}
func (ac *appContext) Log() logging.Logger {
return ac.Context.Logger()
}
func (ac *appContext) Req() *app.ReqInfo {
return ac.req
}
func appContextMiddleware(app *app.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
reqInfo, err := getReqInfoFromHTTPHeaders(c.Request())
if err != nil {
return err
}
ac := &appContext{Context: c, req: reqInfo, app: app}
return next(ac)
}
}
}

55
web/internal/app/app.go Normal file
View File

@ -0,0 +1,55 @@
package app
import (
"net/http"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
type App struct {
cfg *config.Config
ceod model.CeodService
mail service.MailService
}
func NewApp(cfg *config.Config, mailSrv service.MailService) *App {
return &App{
cfg: cfg,
ceod: service.NewCeodService(cfg),
mail: mailSrv,
}
}
func (app *App) GetUser(ctx AppContext, username string) (*model.User, error) {
// TODO: cache user lookups
cscUser, err := app.ceod.GetUser(ctx, username)
if err != nil {
if ceodErr, ok := err.(*model.CeodError); ok &&
ceodErr.HttpStatus == http.StatusNotFound {
return nil, newAppError(ERROR_NO_SUCH_USER)
}
return nil, newAppError(ERROR_OTHER)
}
if cscUser.ShadowExpire == 1 {
return nil, newAppError(ERROR_MEMBERSHIP_EXPIRED)
}
return cscUser, nil
}
func (app *App) GetReqUser(ctx AppContext) (*model.User, error) {
return app.GetUser(ctx, ctx.Req().Username)
}
type ReqInfo struct {
// This info comes from ADFS via SAML
Username string
GivenName string
}
type AppContext interface {
logging.ContextWithLogger
Req() *ReqInfo
}

View File

@ -0,0 +1,22 @@
package app
const (
ERROR_NO_SUCH_USER = iota + 1
ERROR_MEMBERSHIP_EXPIRED
ERROR_FAILED_TO_SEND_PWRESET_EMAIL
ERROR_OTHER
)
type AppError struct {
Code int
}
func newAppError(code int) AppError {
return AppError{Code: code}
}
func (a AppError) Error() string {
// This is just a placeholder; the real error message should be
// provided by the presentation layer
return "App error"
}

View File

@ -0,0 +1,50 @@
package app
import (
"strings"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
// PwresetCheck checks that the client is allowed to reset their password
// and returns the email address to which the new password would be sent.
func (a *App) PwresetCheck(ctx AppContext) (string, error) {
user, err := a.GetReqUser(ctx)
if err != nil {
return "", err
}
return a.getUWEmailAddress(user), nil
}
// Pwreset returns the email address to which the new password was sent
func (a *App) Pwreset(ctx AppContext) (string, error) {
user, err := a.GetReqUser(ctx)
if err != nil {
return "", err
}
newPassword, err := a.ceod.Pwreset(ctx, user.Uid)
if err != nil {
return "", err
}
emailAddress := a.getUWEmailAddress(user)
err = a.mail.Send(ctx, user.Cn, emailAddress, "pwreset_email.txt", map[string]any{
"firstName": user.GivenName,
"newPassword": newPassword,
})
if err != nil {
ctx.Log().Errorf("Failed to send new password email: %w", err)
return "", newAppError(ERROR_FAILED_TO_SEND_PWRESET_EMAIL)
}
return emailAddress, nil
}
func (a *App) getUWEmailAddress(user *model.User) string {
suffix := "@" + a.cfg.UWDomain
for _, address := range user.ForwardingAddresses {
if strings.HasSuffix(address, suffix) {
return address
}
}
// Fallback
return user.Uid + suffix
}

View File

@ -0,0 +1,135 @@
package config
import (
"encoding/json"
"fmt"
"net/url"
"os"
"strings"
)
const (
defaultKrb5Config = "/etc/krb5.conf"
defaultKrb5KtName = "/etc/krb5.keytab"
// The cookie name needs to be very unique because this app is
// being served from the root CSC domain
defaultCookieName = "ceod-web-csrf"
ceodHostname = "phosphoric-acid"
ceodPort = 9987
)
type Config struct {
SocketPath string `json:"socket_path"`
NoSocketAuth bool `json:"no_socket_auth"`
Krb5ConfigPath string `json:"-"`
Krb5KeytabPath string `json:"-"`
AppURL string `json:"app_url"`
CSCDomain string `json:"csc_domain"`
UWDomain string `json:"uw_domain"`
MTA string `json:"mta"`
CookieName string `json:"cookie_name"`
HstsMaxAge int `json:"hsts_max_age"`
IsDev bool `json:"dev"`
ForcedEmailRecipient string `json:"forced_email_recipient"`
appHostname string
appPath string
}
func envGet(name, defaultValue string) string {
if s := os.Getenv(name); s != "" {
return s
}
return defaultValue
}
func NewConfig(cfgPath string) *Config {
cfg := new(Config)
file, err := os.Open(cfgPath)
if err != nil {
panic(fmt.Errorf("Could not open %s: %w", cfgPath, err))
}
defer file.Close()
if err = json.NewDecoder(file).Decode(cfg); err != nil {
panic(fmt.Errorf("Could not decode JSON from %s: %w", cfgPath, err))
}
if cfg.SocketPath == "" {
panic(fmt.Errorf(`"socket_path" is missing from %s`, cfgPath))
}
cfg.Krb5ConfigPath = envGet("KRB5_CONFIG", defaultKrb5Config)
cfg.Krb5KeytabPath = envGet("KRB5_KTNAME", defaultKrb5KtName)
if cfg.CSCDomain == "" {
panic(fmt.Errorf(`"csc_domain" is missing from %s`, cfgPath))
}
if cfg.UWDomain == "" {
panic(fmt.Errorf(`"uw_domain" is missing from %s`, cfgPath))
}
if cfg.AppURL == "" {
panic(fmt.Errorf(`"app_url" is missing from %s`, cfgPath))
}
appURL := cfg.AppURL
if !strings.HasPrefix(appURL, "http://") && !strings.HasPrefix(appURL, "https://") {
appURL = "//" + appURL
}
parsedURL, err := url.Parse(appURL)
if err != nil {
panic(fmt.Errorf(`Could not parse URL "%s": %w`, appURL, err))
}
cfg.appHostname = parsedURL.Hostname()
if cfg.appHostname == "" {
panic(fmt.Errorf(`Could not parse URL "%s"`, appURL))
}
cfg.appPath = parsedURL.Path
if cfg.appPath == "" {
cfg.appPath = "/"
} else if cfg.appPath[len(cfg.appPath)-1] != '/' {
cfg.appPath += "/"
}
if cfg.CookieName == "" {
cfg.CookieName = defaultCookieName
}
if !cfg.IsDev && cfg.MTA == "" {
panic(fmt.Errorf(`"mta" is missing from %s`, cfgPath))
}
if !cfg.IsDev && cfg.ForcedEmailRecipient != "" {
panic(fmt.Errorf(`"forced_email_recipient" may only be used in development`))
}
return cfg
}
func getHostnameOrDie() string {
hostname, err := os.Hostname()
if err != nil {
panic(fmt.Errorf("Failed to get hostname: %w", err))
}
return hostname
}
func (c *Config) GetCeodBaseURL() string {
scheme := "https"
if c.IsDev {
scheme = "http"
}
return fmt.Sprintf("%s://%s.%s:%d", scheme, ceodHostname, c.CSCDomain, ceodPort)
}
func (c *Config) GetKrb5Principal() string {
hostname := getHostnameOrDie()
return fmt.Sprintf("ceod/%s.%s", hostname, c.CSCDomain)
}
func (c *Config) GetKrb5Realm() string {
return strings.ToUpper(c.CSCDomain)
}
func (c *Config) GetCeodSPN() string {
return fmt.Sprintf("ceod/%s.%s", ceodHostname, c.CSCDomain)
}
func (c *Config) GetAppHostname() string {
return c.appHostname
}
func (c *Config) GetAppPath() string {
return c.appPath
}

9
web/internal/internal.go Normal file
View File

@ -0,0 +1,9 @@
package internal
import "embed"
//go:embed views/*
var EmbeddedViews embed.FS
//go:embed static/*
var EmbeddedAssets embed.FS

View File

@ -0,0 +1,213 @@
package service
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
krb5client "github.com/jcmturner/gokrb5/v8/client"
krb5config "github.com/jcmturner/gokrb5/v8/config"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/jcmturner/gokrb5/v8/spnego"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
type Ceod struct {
apiBaseURL string
spnegoClient *spnego.Client
}
func NewCeodService(cfg *config.Config) model.CeodService {
krb5cfg, err := krb5config.Load(cfg.Krb5ConfigPath)
if err != nil {
panic(fmt.Errorf("Failed to load %s: %w", cfg.Krb5ConfigPath, err))
}
kt, err := keytab.Load(cfg.Krb5KeytabPath)
if err != nil {
panic(fmt.Errorf("Failed to load %s: %w", cfg.Krb5KeytabPath, err))
}
cl := krb5client.NewWithKeytab(cfg.GetKrb5Principal(), cfg.GetKrb5Realm(), kt, krb5cfg)
err = cl.Login()
if err != nil {
panic(fmt.Errorf("Failed to login to KDC: %w", err))
}
return &Ceod{
apiBaseURL: cfg.GetCeodBaseURL(),
spnegoClient: spnego.NewClient(cl, nil, cfg.GetCeodSPN()),
}
}
// TODO: auto-generate these from the OpenAPI definition
type ErrorResponse struct {
Error string `json:"error"`
}
type PwresetResponse struct {
Password string `json:"password"`
}
func (c *Ceod) getResp(ctx model.CeodRequestContext, method, urlPath string, body any) (*http.Response, error) {
ctx.Log().Debugf("%s %s", method, urlPath)
var bodyReader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("Could not marshal body: %w", err)
}
bodyReader = bytes.NewBuffer(buf)
}
r, err := http.NewRequest(method, c.apiBaseURL+urlPath, bodyReader)
if err != nil {
return nil, fmt.Errorf("Could not build HTTP request: %w", err)
}
if bodyReader != nil {
r.Header.Set("Content-Type", "application/json")
}
resp, err := c.spnegoClient.Do(r)
if err != nil {
return nil, fmt.Errorf("SPNEGO request failed: %w", err)
}
return resp, nil
}
func getRespError(ctx model.CeodRequestContext, resp *http.Response) error {
if resp.StatusCode/100 == 2 {
return nil
}
rawBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf(
"HTTP status=%d, and failed to read body: %w",
resp.StatusCode, err,
)
}
var errBody ErrorResponse
err = json.Unmarshal(rawBody, &errBody)
var message string
if err != nil {
message = string(rawBody)
} else {
message = errBody.Error
}
ctx.Log().Infof("ceod response: status=%d message=%s", resp.StatusCode, message)
return &model.CeodError{HttpStatus: resp.StatusCode, Message: message}
}
func (c *Ceod) request(ctx model.CeodRequestContext, method, urlPath string, respBody any) error {
resp, err := c.getResp(ctx, method, urlPath, nil)
if err != nil {
return err
}
defer resp.Body.Close()
err = getRespError(ctx, resp)
if err != nil {
return err
}
err = json.NewDecoder(resp.Body).Decode(respBody)
if err != nil {
return fmt.Errorf("Failed to decode JSON response: %w", err)
}
return nil
}
func parseTransactionMessage[T any](b []byte) (model.ITransactionMessage, error) {
var txn model.TransactionMessage
if err := json.Unmarshal(b, &txn); err != nil {
return nil, fmt.Errorf("Could not decode JSON: '%s'", string(b))
}
var itxn model.ITransactionMessage
switch txn.Status {
case "in progress":
itxn = new(model.TransactionInProgress)
case "completed":
itxn = new(model.TransactionCompleted[T])
case "aborted":
itxn = new(model.TransactionAborted)
default:
return nil, fmt.Errorf("Unrecognized transaction status '%s'", txn.Status)
}
if err := json.Unmarshal(b, itxn); err != nil {
return nil, fmt.Errorf("Could not decode JSON: '%s'", string(b))
}
return itxn, nil
}
func requestTransaction[T any](
c *Ceod, ctx model.CeodTransactionRequestContext,
method, urlPath string, body any,
) (<-chan model.ITransactionMessage, error) {
resp, err := c.getResp(ctx, method, urlPath, body)
if err != nil {
return nil, err
}
err = getRespError(ctx, resp)
if err != nil {
resp.Body.Close()
return nil, err
}
txnChan := make(chan model.ITransactionMessage)
go func() {
defer resp.Body.Close()
defer close(txnChan)
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
msg, err := parseTransactionMessage[T](scanner.Bytes())
if err != nil {
ctx.Log().Error(err)
return
}
select {
case txnChan <- msg:
continue
case <-ctx.CancelChan():
ctx.Log().Warn("Cancel chan closed, returning")
return
}
}
if err := scanner.Err(); err != nil {
ctx.Log().Error(err)
}
}()
return txnChan, nil
}
func (c *Ceod) GetUser(ctx model.CeodRequestContext, username string) (*model.User, error) {
user := &model.User{}
err := c.request(ctx, "GET", "/api/members/"+username, user)
return user, err
}
func (c *Ceod) GetUWUser(ctx model.CeodRequestContext, username string) (*model.UWUser, error) {
uwUser := &model.UWUser{}
err := c.request(ctx, "GET", "/api/uwldap/"+username, uwUser)
return uwUser, err
}
func (c *Ceod) Pwreset(ctx model.CeodRequestContext, username string) (string, error) {
resp := &PwresetResponse{}
err := c.request(ctx, "POST", "/api/members/"+username+"/pwreset", resp)
if err != nil {
return "", err
}
return resp.Password, nil
}
func (c *Ceod) AddUser(
ctx model.CeodTransactionRequestContext,
req *model.AddUserRequest,
) (<-chan model.ITransactionMessage, error) {
return requestTransaction[model.UserWithPassword](c, ctx, "POST", "/api/members", req)
}
func (c *Ceod) DeleteUser(
ctx model.CeodTransactionRequestContext,
username string,
) (<-chan model.ITransactionMessage, error) {
return requestTransaction[string](c, ctx, "DELETE", "/api/members/"+username, nil)
}

View File

@ -0,0 +1,129 @@
package service
import (
"bytes"
"embed"
"fmt"
"net/smtp"
"strings"
"text/template"
"time"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
)
type MailService interface {
Send(
ctx logging.ContextWithLogger,
recipientName, recipientAddress, tmplName string,
data map[string]any,
) error
}
type MailBackend interface {
Send(addr string, from string, to []string, msg []byte) error
}
type mail struct {
cfg *config.Config
backend MailBackend
}
type prodMailBackend struct{}
type devMailBackend struct{}
//go:embed pwreset_email.txt
var emailTemplateFiles embed.FS
var emailTemplates = make(map[string]*template.Template)
func init() {
templateFiles := []string{"pwreset_email.txt"}
for _, filename := range templateFiles {
contentBytes, err := emailTemplateFiles.ReadFile(filename)
if err != nil {
panic(err)
}
contentStr := string(contentBytes)
// The lines of the body need to be CRLF terminated
// See https://pkg.go.dev/net/smtp#SendMail
if strings.Contains(contentStr, "\r") {
panic(fmt.Errorf("File %s should not have carriage returns", filename))
}
contentStr = strings.ReplaceAll(contentStr, "\n", "\r\n")
emailTemplates[filename] = template.Must(template.New(filename).Parse(contentStr))
}
}
func NewMailServiceWithBackend(cfg *config.Config, backend MailBackend) MailService {
return &mail{cfg, backend}
}
func NewMailService(cfg *config.Config) MailService {
var backend MailBackend
if cfg.IsDev && cfg.ForcedEmailRecipient == "" {
backend = devMailBackend{}
} else {
backend = prodMailBackend{}
}
return NewMailServiceWithBackend(cfg, backend)
}
func (m *mail) senderAddress() string {
return "ceod+web@" + m.cfg.CSCDomain
}
func (m *mail) render(
recipientName, recipientAddress, tmplName string,
data map[string]any,
) ([]byte, error) {
tmpl, ok := emailTemplates[tmplName]
if !ok {
return nil, fmt.Errorf("No such email template '%s'", tmplName)
}
data["emailSenderName"] = "CSC Electronic Office"
data["emailSender"] = m.senderAddress()
data["emailRecipientName"] = recipientName
data["emailRecipient"] = recipientAddress
data["emailReplyTo"] = "no-reply@" + m.cfg.CSCDomain
data["emailDate"] = time.Now().Format(time.RFC1123Z)
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("Failed to render template %s: %w", tmplName, err)
}
return buf.Bytes(), nil
}
func (m *mail) Send(
ctx logging.ContextWithLogger,
recipientName, recipientAddress, tmplName string,
data map[string]any,
) error {
msg, err := m.render(recipientName, recipientAddress, tmplName, data)
if err != nil {
ctx.Log().Error(err)
return err
}
ctx.Log().Debugf("Sending email to %s", recipientAddress)
realRecipientAddress := recipientAddress
if m.cfg.ForcedEmailRecipient != "" {
realRecipientAddress = m.cfg.ForcedEmailRecipient
}
return m.backend.Send(m.cfg.MTA, m.senderAddress(), []string{realRecipientAddress}, msg)
}
func (m devMailBackend) Send(addr string, from string, to []string, msg []byte) error {
fmt.Printf("Would have sent this email:\n%s", string(msg))
return nil
}
func (m prodMailBackend) Send(addr string, from string, to []string, msg []byte) error {
return smtp.SendMail(
addr,
nil, // auth
from,
to,
msg,
)
}

View File

@ -0,0 +1,23 @@
From: {{ .emailSenderName }} <{{ .emailSender }}>
To: {{ .emailRecipientName }} <{{ .emailRecipient }}>
Reply-To: {{ .emailReplyTo }}
Subject: Computer Science Club Password Reset
Date: {{ .emailDate }}
Hello {{ .firstName }},
Your new temporary CSC password is:
{{ .newPassword }}
Please SSH into any general-use machine and change this password (you
will be prompted to do so when you login). Here are instructions on
how to do so:
https://wiki.csclub.uwaterloo.ca/How_to_SSH
If you have any questions or concerns, please contact
syscom@csclub.uwaterloo.ca.
Regards,
ceo

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="574px" height="252px" viewBox="0 0 574 252" preserveAspectRatio="xMidYMid meet">
<g fill="#000000">
<path d="M112.3 251 c-53.3 -5.6 -96.7 -43.8 -109.3 -96 -3.2 -13.1 -3.9 -36.8 -1.6 -50.6 10.6 -62.2 65.8 -106.8 128.8 -104.1 24.3 1 45.8 7.9 65.7 21.1 7.4 5 18.5 14.3 18.9 16 0.1 0.6 -2.7 2 -6.4 3 -29.7 8.5 -53.3 33 -61.6 64.1 -1.7 6.3 -2.2 10.9 -2.2 21.5 -0.1 11.9 0.3 14.6 2.7 23 4.9 16.6 15.7 33.8 27.2 43.3 l4.6 3.9 4.5 -3.8 c19 -15.5 31.4 -44.2 31.4 -72.7 0 -14.8 5.1 -35 12.7 -50.2 21.4 -42.6 64.8 -69.4 112.3 -69.5 16.3 0 35.6 3.7 48.4 9.4 l5.5 2.4 7.2 -2.9 c8.7 -3.6 19.6 -6.4 30.8 -8 10.4 -1.5 31.7 -0.6 42.1 1.6 46.8 10.3 83.5 45.2 95.5 91 8 30.3 4.6 61.9 -9.8 89.9 -17.5 34.2 -48.5 57.6 -87 65.7 -11.8 2.5 -32.8 3 -44.3 1.1 -23.8 -4 -45.5 -14.3 -65.1 -31 -2.9 -2.4 -5.3 -4.7 -5.3 -5.1 0 -0.4 3.3 -1.8 7.3 -3 36.4 -11.7 60.2 -41.6 64.1 -80.6 1.8 -17.9 -7.2 -44.5 -20.4 -60.7 -4.9 -6 -13.4 -13.8 -15 -13.8 -1.5 0 -11 9.2 -15.8 15.3 -6.7 8.6 -11.5 17.7 -15.1 29 -2.5 7.9 -3.2 12.4 -4.1 25.4 -1.3 19.1 -2.2 24.8 -6.1 37.8 -19.5 63.6 -86.9 101 -151.7 84 -5.1 -1.4 -12.2 -3.6 -15.6 -5 l-6.3 -2.5 -8.9 3.4 c-18.6 7 -38.5 9.6 -58.1 7.6z m28.1 -38 c0.5 -0.5 -0.9 -2.9 -3.7 -6 -21.4 -23.8 -33 -62.5 -28.7 -95.6 3.2 -25 16.2 -53.7 31.7 -70.6 l2.5 -2.6 -6.8 -0.7 c-42 -4.5 -82.6 22.3 -94.7 62.5 -5.7 19 -5.6 33.8 0.3 52.4 10.5 32.4 38.5 56.2 72.8 61.6 6.7 1 25.2 0.4 26.6 -1z m103.6 1 c16.8 -2.7 32.7 -9.7 45.1 -19.8 20.2 -16.4 32.8 -43.2 32.9 -69.7 0.1 -28.2 11.1 -57.7 29.8 -80.2 l5.1 -6.1 -7.6 -0.8 c-35.5 -3.3 -69.8 15.1 -87 46.6 -6.9 12.8 -11.3 30.8 -11.3 46.8 0 25.2 -11.3 54.6 -29.3 76.2 -2.5 3 -4.4 5.7 -4.1 5.9 1.8 1.9 17.5 2.5 26.4 1.1z m215 0 c41.5 -6.6 72 -37.7 77.2 -78.7 2.1 -16.3 -1.7 -35.3 -10.2 -51 -17.2 -32 -51.3 -50.2 -87.3 -46.8 l-7.7 0.7 4.5 5 c13.1 14.5 23.4 35.2 28 55.8 2.2 10.1 3.1 32.7 1.6 43.2 -3.1 21.6 -12.9 44.7 -26.5 62.3 -4.2 5.4 -6.2 8.7 -5.4 9 4.3 1.5 17.9 1.7 25.8 0.5z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

349
web/internal/static/normalize.css vendored Normal file
View File

@ -0,0 +1,349 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

View File

@ -0,0 +1,67 @@
*, ::after, ::before {
box-sizing: border-box;
}
body {
min-height: 100vh;
padding: 0.2rem 1rem;
display: flex;
flex-direction: column;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif;
background-color: var(--background-primary);
color: var(--text-primary);
}
:root {
--background-primary: #fff;
--text-primary: #1b1b1b;
--text-link: #0069c2;
--text-visited: #551a8b;
--accent-primary: #0085f2;
}
@media (prefers-color-scheme: dark) {
:root {
--background-primary: #1b1b1b;
--text-primary: #fff;
--text-link: #8cb4ff;
--text-visited: #ffadff;
--accent-primary: #5e9eff;
}
}
a {
color: var(--text-link);
}
a:active, a:active:visited {
background-color: var(--text-link);
color: #fff;
}
a:visited {
color: var(--text-visited);
}
:focus-visible {
outline-color: var(--accent-primary);
outline-offset: 1px;
}
input[type="submit"]:hover {
cursor: pointer;
}
main {
flex: 1 0;
}
footer {
font-size: 0.8rem;
}
.mt-10 { margin-top: 2.5rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }

View File

@ -0,0 +1,7 @@
{{ define "title" }}{{ .statusText }}{{ end }}
{{ define "main" }}
<p>
Please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>
if you are in need of assistance.
</p>
{{ end }}

View File

@ -0,0 +1,7 @@
{{ define "title" }}Internal server error{{ end }}
{{ define "main" }}
<p>
Oops! Please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>
for assistance.
</p>
{{ end }}

View File

@ -0,0 +1,4 @@
{{ define "title" }}{{ .Title }}{{ end }}
{{ define "main" }}
<p>{{ .HtmlFragment }}</p>
{{ end }}

View File

@ -0,0 +1,20 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="static/csc-logo.svg">
<link rel="stylesheet" href="static/normalize.css">
<link rel="stylesheet" href="static/styles.css">
<title>{{ block "title" . }}CSC Electronic Office{{ end }}</title>
</head>
<body>
<header>
<h1>{{ template "title" . }}</h1>
<hr>
</header>
<main>{{ block "main" . }}{{ end }}</main>
<footer>
Copyright &copy; 2024 Computer Science Club of the University of Waterloo
</footer>
</body>
</html>

View File

@ -0,0 +1,26 @@
{{ define "title" }}Password Reset{{ end }}
{{ define "main" }}
<p>
<strong>NOTE</strong>: if you are able to SSH into a general-use machine
using a public key, then you do not need to use this form. Simply run
<code>passwd</code> instead.
</p>
<p>
Clicking the button below will generate a new temporary password for your
account which will be sent to the following email address:
</p>
<p>
<strong>{{ .emailAddress }}</strong>
</p>
<p>
If you do not have access to the email address above, please contact the
<a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a> for assistance.
</p>
<form method="post">
<input type="hidden" name="_csrf" value="{{ .csrf }}" />
<input type="submit" class="py-2 px-6 text-lg" value="Reset my password" />
</form>
<p class="mt-10">
<a href=".">Return home</a>
</p>
{{ end }}

View File

@ -0,0 +1,14 @@
{{ define "title" }}Password Reset Confirmation{{ end }}
{{ define "main" }}
<p>
A new temporary password was sent to <b>{{ .emailAddress }}</b>.
</p>
<p>
PLEASE CHECK YOUR JUNK FOLDER. MS Outlook has an unfortunate tendency to place
our emails there. If you still have not received the confirmation email after
20 minutes, please contact the <a href="mailto:syscom@csclub.uwaterloo.ca">Systems Committee</a>.
</p>
<p class="mt-10">
<a href=".">Return home</a>
</p>
{{ end }}

View File

@ -0,0 +1,11 @@
{{ define "title" }}CSC Electronic Office{{ end }}
{{ define "main" }}
<p>
Hello, {{ .firstName }}. What would you like to do?
</p>
<ul>
<li>
<a href="pwreset">Reset password</a>
</li>
</ul>
{{ end }}

18
web/main.go Normal file
View File

@ -0,0 +1,18 @@
package main
import (
"flag"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/api"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
)
func main() {
configPath := flag.String("c", "", "config file")
flag.Parse()
if *configPath == "" {
panic("Config file must be specified")
}
cfg := config.NewConfig(*configPath)
api.Start(cfg)
}

View File

@ -0,0 +1,16 @@
package logging
type Logger interface {
Debug(i ...interface{})
Debugf(format string, args ...interface{})
Info(i ...interface{})
Infof(format string, args ...interface{})
Warn(i ...interface{})
Warnf(format string, args ...interface{})
Error(i ...interface{})
Errorf(format string, args ...interface{})
}
type ContextWithLogger interface {
Log() Logger
}

115
web/pkg/model/ceod.go Normal file
View File

@ -0,0 +1,115 @@
package model
import "git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
// TODO: auto-generate using OpenAPI definition
type CeodError struct {
HttpStatus int
Message string
}
func (e *CeodError) Error() string {
return e.Message
}
type CeodRequestContext logging.ContextWithLogger
type CeodTransactionRequestContext interface {
logging.ContextWithLogger
CancelChan() <-chan struct{}
}
type CeodService interface {
GetUser(ctx CeodRequestContext, username string) (*User, error)
GetUWUser(ctx CeodRequestContext, username string) (*UWUser, error)
Pwreset(ctx CeodRequestContext, username string) (string, error)
// On success, last message will be TransactionCompleted[UserWithPassword]
AddUser(ctx CeodTransactionRequestContext, req *AddUserRequest) (<-chan ITransactionMessage, error)
// On success, last message will be string "OK"
DeleteUser(ctx CeodTransactionRequestContext, username string) (<-chan ITransactionMessage, error)
}
type AddUserRequest struct {
// Full name
Cn string `json:"cn"`
// Last name
Sn string `json:"sn"`
// First name
GivenName string `json:"given_name"`
// Username
Uid string `json:"uid"`
Program string `json:"program"`
Terms int `json:"terms,omitempty"`
NonMemberTerms int `json:"non_member_terms,omitempty"`
ForwardingAddresses []string `json:"forwarding_addresses"`
}
type User struct {
// Full name
Cn string `json:"cn"`
// Last name
Sn string `json:"sn"`
// First name
GivenName string `json:"given_name"`
// Username
Uid string `json:"uid"`
UidNumber int `json:"uid_number"`
GidNumber int `json:"gid_number"`
HomeDirectory string `json:"home_directory"`
LoginShell string `json:"login_shell"`
Groups []string `json:"groups"`
Program string `json:"program"`
// Terms will be absent for club reps
Terms []string `json:"terms,omitempty"`
// NonMemberTerms will be absent for general members
NonMemberTerms []string `json:"non_member_terms,omitempty"`
MailLocalAddresses []string `json:"mail_local_addresses"`
// ForwardingAddresses will be absent if the client does not have
// sufficient permissions. It will be empty if the client's
// ~/.forward is missing or empty.
ForwardingAddresses []string `json:"forwarding_addresses,omitempty"`
IsClub bool `json:"is_club"`
IsClubRep bool `json:"is_club_rep"`
// ShadowExpire will be 1 if the user's account is expired, and
// absent otherwise.
ShadowExpire int `json:"shadow_expire,omitempty"`
}
type UWUser struct {
// Username
Uid string `json:"uid"`
MailLocalAddresses []string `json:"mail_local_addresses"`
// The following fields might be absent for alumni
Cn string `json:"cn,omitempty"`
GivenName string `json:"given_name,omitempty"`
Sn string `json:"sn,omitempty"`
// This field will always be absent for alumni
Program string `json:"program,omitempty"`
}
type ITransactionMessage interface{}
type TransactionMessage struct {
Status string `json:"status"`
}
type TransactionInProgress struct {
TransactionMessage // "in progress"
Operation string `json:"operation"`
}
type TransactionAborted struct {
TransactionMessage // "aborted"
Error string `json:"error"`
}
type TransactionCompleted[T any] struct {
TransactionMessage // "completed"
Result T `json:"result"`
}
type UserWithPassword struct {
User
Password string `json:"password"`
}

7
web/prod.json Normal file
View File

@ -0,0 +1,7 @@
{
"app_url": "https://csclub.uwaterloo.ca/ceo",
"socket_path": "/run/ceod-web/app.sock",
"csc_domain": "csclub.uwaterloo.ca",
"uw_domain": "uwaterloo.ca",
"mta": "mail.csclub.uwaterloo.ca:25"
}

75
web/scripts/proxy.go Normal file
View File

@ -0,0 +1,75 @@
package main
// Adapted from https://gist.github.com/yowu/f7dc34bd4736a65ff28d
import (
"flag"
"fmt"
"io"
"net"
"net/http"
"os"
)
var (
username string
firstName string
port int
sockPath string
)
func dial(proto, addr string) (net.Conn, error) {
return net.Dial("unix", sockPath)
}
func newClient() *http.Client {
transport := &http.Transport{Dial: dial}
return &http.Client{Transport: transport}
}
type Proxy struct{}
func (p *Proxy) ServeHTTP(wr http.ResponseWriter, req *http.Request) {
client := newClient()
proxyReq, err := http.NewRequest(req.Method, "http://localhost"+req.RequestURI, req.Body)
if err != nil {
panic(err)
}
proxyReq.Header = req.Header
proxyReq.Header["X-CSC-ADFS-Username"] = []string{username}
proxyReq.Header["X-CSC-ADFS-FirstName"] = []string{firstName}
resp, err := client.Do(proxyReq)
if err != nil {
panic(err)
}
for k, v := range resp.Header {
wr.Header()[k] = v
}
wr.WriteHeader(resp.StatusCode)
_, err = io.Copy(wr, resp.Body)
if err != nil {
panic(err)
}
}
func main() {
sockPtr := flag.String("s", "", "socket path to proxy to")
portPtr := flag.Int("p", 9988, "port to listen on")
usernamePtr := flag.String("u", "", "username")
firstNamePtr := flag.String("f", "", "first name")
flag.Parse()
sockPath = *sockPtr
port = *portPtr
username = *usernamePtr
firstName = *firstNamePtr
if sockPath == "" || username == "" || firstName == "" {
fmt.Fprint(os.Stderr, "Usage: proxy [-p port] -s <socket path> -u <username> -f <first name>\n")
os.Exit(1)
}
addr := fmt.Sprintf("127.0.0.1:%d", port)
fmt.Fprintf(os.Stderr, "Listening on %s\n", addr)
err := http.ListenAndServe(addr, &Proxy{})
if err != nil {
panic(err)
}
}

View File

@ -0,0 +1,18 @@
[Unit]
Description=CSC Electronic Office daemon (web)
Documentation=https://git.csclub.uwaterloo.ca/public/pyceo
Wants=apache2.service
After=apache2.service
[Service]
Type=exec
WorkingDirectory=/srv/pyceo/web
ExecStart=/srv/pyceo/web/ceod-web -c prod.json
RuntimeDirectory=ceod-web
DynamicUser=yes
LoadCredential=krb5.keytab:/etc/krb5.keytab
Environment=KRB5_KTNAME=%d/krb5.keytab
ProtectSystem=strict
[Install]
WantedBy=multi-user.target

202
web/tests/common.go Normal file
View File

@ -0,0 +1,202 @@
package tests
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"strings"
"sync"
"github.com/PuerkitoBio/goquery"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/api"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/config"
"git.csclub.uwaterloo.ca/public/pyceo/web/internal/service"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/logging"
"git.csclub.uwaterloo.ca/public/pyceo/web/pkg/model"
)
// Make sure app_url is using "127.0.0.1", not "localhost", because that is
// what httptest.Server uses, and the cookie domain needs to match exactly
var Cfg = config.NewConfig("tests/test.json")
var Ceod = service.NewCeodService(Cfg)
var TestMail = TestMailBackend{}
var TestServer = httptest.NewServer(api.NewAPI(
Cfg, Ceod, service.NewMailServiceWithBackend(Cfg, &TestMail),
))
type MockEmail struct {
MTA string
From string
To []string
Msg []byte
}
type TestMailBackend struct {
SentEmails []MockEmail
mutex sync.Mutex
}
func (m *TestMailBackend) Send(addr string, from string, to []string, msg []byte) error {
msg = bytes.ReplaceAll(msg, []byte("\r\n"), []byte("\n"))
m.mutex.Lock()
m.SentEmails = append(m.SentEmails, MockEmail{addr, from, to, msg})
m.mutex.Unlock()
return nil
}
func (m *TestMailBackend) Reset() {
m.SentEmails = nil
}
var simpleUsernameCounter int
var simpleUsernameMutex sync.Mutex
func getNextSimpleUsername() string {
simpleUsernameMutex.Lock()
simpleUsernameCounter += 1
i := simpleUsernameCounter
simpleUsernameMutex.Unlock()
return fmt.Sprintf("ceodweb%d", i)
}
type TestLogger struct{}
var SharedTestLogger TestLogger
func (t *TestLogger) Debug(i ...interface{}) {}
func (t *TestLogger) Debugf(format string, args ...interface{}) {}
func (t *TestLogger) Info(i ...interface{}) {}
func (t *TestLogger) Infof(format string, args ...interface{}) {}
func (t *TestLogger) Warn(i ...interface{}) {
log.Println(i...)
}
func (t *TestLogger) Warnf(format string, args ...interface{}) {
log.Printf(format+"\n", args...)
}
func (t *TestLogger) Error(i ...interface{}) {
log.Println(i...)
}
func (t *TestLogger) Errorf(format string, args ...interface{}) {
log.Printf(format+"\n", args...)
}
type TestCeodTransactionRequestContext struct {
logger *TestLogger
cancelChan <-chan struct{}
}
func (t *TestCeodTransactionRequestContext) Log() logging.Logger {
return t.logger
}
func (t *TestCeodTransactionRequestContext) CancelChan() <-chan struct{} {
return t.cancelChan
}
func NewCeodTransactionRequestContext(cancelChan <-chan struct{}) model.CeodTransactionRequestContext {
return &TestCeodTransactionRequestContext{logger: &SharedTestLogger, cancelChan: cancelChan}
}
func handleTxn[T any](txnChan <-chan model.ITransactionMessage, err error) *T {
if err != nil {
panic(err)
}
for msg := range txnChan {
if _, ok := msg.(*model.TransactionInProgress); ok {
// ignore
} else if abortedMsg, ok := msg.(*model.TransactionAborted); ok {
panic(abortedMsg.Error)
} else if completedMsg, ok := msg.(*model.TransactionCompleted[T]); ok {
return &completedMsg.Result
} else {
panic(fmt.Errorf("Unrecognized message type: %+v", msg))
}
}
panic("Did not receive completed transaction message")
}
func AddSimpleUser() *model.UserWithPassword {
username := getNextSimpleUsername()
cancelChan := make(chan struct{})
defer close(cancelChan)
ctx := NewCeodTransactionRequestContext(cancelChan)
req := model.AddUserRequest{
Uid: username,
Cn: "John Doe",
GivenName: "John",
Sn: "Doe",
Program: "MAT/Mathematics Computer Science",
Terms: 1,
ForwardingAddresses: []string{username + "@" + Cfg.UWDomain},
}
return handleTxn[model.UserWithPassword](Ceod.AddUser(ctx, &req))
}
func DeleteUser(username string) {
cancelChan := make(chan struct{})
defer close(cancelChan)
ctx := NewCeodTransactionRequestContext(cancelChan)
result := *handleTxn[string](Ceod.DeleteUser(ctx, username))
if result != "OK" {
panic(fmt.Errorf("Result was not 'OK': '%s'", result))
}
}
type TestClient struct {
User *model.UserWithPassword
client http.Client
}
func NewTestClientFromUser(user *model.UserWithPassword) *TestClient {
return &TestClient{User: user, client: http.Client{}}
}
func NewTestClient() *TestClient {
jar, err := cookiejar.New(nil)
if err != nil {
panic(err)
}
return &TestClient{
User: AddSimpleUser(),
client: http.Client{Jar: jar},
}
}
// Destroy must be called if the client was created with NewTestClient()
func (c *TestClient) Destroy() {
DeleteUser(c.User.Uid)
}
func (c *TestClient) request(method, urlPath string, formData url.Values) (*goquery.Document, error) {
var bodyReader io.Reader
if formData != nil {
bodyReader = strings.NewReader(formData.Encode())
}
r, err := http.NewRequest(method, TestServer.URL+urlPath, bodyReader)
if err != nil {
return nil, fmt.Errorf("Could not build HTTP request: %w", err)
}
if bodyReader != nil {
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
r.Header.Set("X-CSC-ADFS-Username", c.User.Uid)
r.Header.Set("X-CSC-ADFS-FirstName", c.User.GivenName)
resp, err := c.client.Do(r)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return goquery.NewDocumentFromReader(resp.Body)
}
func (c *TestClient) Get(urlPath string) (*goquery.Document, error) {
return c.request("GET", urlPath, nil)
}
func (c *TestClient) Post(urlPath string, formData url.Values) (*goquery.Document, error) {
return c.request("POST", urlPath, formData)
}

48
web/tests/pwreset_test.go Normal file
View File

@ -0,0 +1,48 @@
package tests
import (
"fmt"
"net/url"
"regexp"
"testing"
"github.com/stretchr/testify/require"
)
func TestPwreset(t *testing.T) {
client := NewTestClient()
defer client.Destroy()
doc, err := client.Get("/pwreset")
require.Nil(t, err)
inputs := doc.Find("form input")
csrfInput := inputs.Filter(`[name="_csrf"]`)
require.Equal(t, 1, csrfInput.Length())
csrfToken, ok := csrfInput.Attr("value")
require.True(t, ok)
submitInput := inputs.Filter(`[type="submit"]`)
require.Equal(t, 1, submitInput.Length())
submitInputValue, ok := submitInput.Attr("value")
require.True(t, ok)
require.Equal(t, "Reset my password", submitInputValue)
defer TestMail.Reset()
doc, err = client.Post("/pwreset", url.Values{"_csrf": {csrfToken}})
require.Nil(t, err)
para := doc.Find("p").First()
uwEmail := client.User.ForwardingAddresses[0]
require.Contains(
t,
para.Text(),
fmt.Sprintf("A new temporary password was sent to %s.", uwEmail),
)
require.Len(t, TestMail.SentEmails, 1)
emailMsg := &TestMail.SentEmails[0]
require.Equal(t, "ceod+web@csclub.internal", emailMsg.From)
require.Equal(t, []string{uwEmail}, emailMsg.To)
matched, err := regexp.Match("(?m)^Subject: Computer Science Club Password Reset$", emailMsg.Msg)
require.Nil(t, err)
require.True(t, matched)
emailBodyStr := string(emailMsg.Msg)
require.Contains(t, emailBodyStr, "Your new temporary CSC password is:\n\n")
}

7
web/tests/test.json Normal file
View File

@ -0,0 +1,7 @@
{
"app_url": "http://127.0.0.1",
"socket_path": "app.sock",
"csc_domain": "csclub.internal",
"uw_domain": "uwaterloo.internal",
"dev": true
}